Skip to content

Commit 987f1ec

Browse files
committed
feat: add useScrollFade and useScrollFadeAll composables for scroll-triggered animations
1 parent fefd63c commit 987f1ec

3 files changed

Lines changed: 1034 additions & 152 deletions

File tree

app/assets/css/main.css

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,3 +142,140 @@
142142
color: var(--color-surface-100);
143143
}
144144
}
145+
146+
/* ── Premium fade-in animations (Linear-style) ─────────── */
147+
148+
/* Hero stagger: plays immediately on load with delay offsets */
149+
@keyframes fade-in-up {
150+
from {
151+
opacity: 0;
152+
filter: blur(6px);
153+
transform: translateY(24px);
154+
}
155+
to {
156+
opacity: 1;
157+
filter: blur(0px);
158+
transform: translateY(0);
159+
}
160+
}
161+
162+
@keyframes fade-in-scale {
163+
from {
164+
opacity: 0;
165+
filter: blur(4px);
166+
transform: translateY(32px) scale(0.97);
167+
}
168+
to {
169+
opacity: 1;
170+
filter: blur(0px);
171+
transform: translateY(0) scale(1);
172+
}
173+
}
174+
175+
/* Base class — elements start invisible */
176+
.hero-animate {
177+
opacity: 0;
178+
animation: fade-in-up 0.9s cubic-bezier(0.16, 1, 0.3, 1) forwards;
179+
}
180+
181+
.hero-animate-scale {
182+
opacity: 0;
183+
animation: fade-in-scale 1.1s cubic-bezier(0.16, 1, 0.3, 1) forwards;
184+
}
185+
186+
/* Stagger delays */
187+
.hero-delay-1 { animation-delay: 0ms; }
188+
.hero-delay-2 { animation-delay: 100ms; }
189+
.hero-delay-3 { animation-delay: 200ms; }
190+
.hero-delay-4 { animation-delay: 340ms; }
191+
.hero-delay-5 { animation-delay: 500ms; }
192+
193+
/* Scroll-triggered: starts hidden, revealed by IntersectionObserver */
194+
.scroll-fade {
195+
opacity: 0;
196+
transform: translateY(28px);
197+
filter: blur(4px);
198+
transition:
199+
opacity 0.8s cubic-bezier(0.16, 1, 0.3, 1),
200+
transform 0.8s cubic-bezier(0.16, 1, 0.3, 1),
201+
filter 0.8s cubic-bezier(0.16, 1, 0.3, 1);
202+
}
203+
204+
.scroll-fade.is-visible {
205+
opacity: 1;
206+
transform: translateY(0);
207+
filter: blur(0px);
208+
}
209+
210+
/* Staggered children inside a scroll-fade container */
211+
.scroll-fade .stagger-child {
212+
opacity: 0;
213+
transform: translateY(20px);
214+
filter: blur(3px);
215+
transition:
216+
opacity 0.7s cubic-bezier(0.16, 1, 0.3, 1),
217+
transform 0.7s cubic-bezier(0.16, 1, 0.3, 1),
218+
filter 0.7s cubic-bezier(0.16, 1, 0.3, 1);
219+
}
220+
221+
.scroll-fade.is-visible .stagger-child:nth-child(1) { opacity: 1; transform: translateY(0); filter: blur(0px); transition-delay: 0ms; }
222+
.scroll-fade.is-visible .stagger-child:nth-child(2) { opacity: 1; transform: translateY(0); filter: blur(0px); transition-delay: 80ms; }
223+
.scroll-fade.is-visible .stagger-child:nth-child(3) { opacity: 1; transform: translateY(0); filter: blur(0px); transition-delay: 160ms; }
224+
.scroll-fade.is-visible .stagger-child:nth-child(4) { opacity: 1; transform: translateY(0); filter: blur(0px); transition-delay: 240ms; }
225+
.scroll-fade.is-visible .stagger-child:nth-child(5) { opacity: 1; transform: translateY(0); filter: blur(0px); transition-delay: 320ms; }
226+
.scroll-fade.is-visible .stagger-child:nth-child(6) { opacity: 1; transform: translateY(0); filter: blur(0px); transition-delay: 400ms; }
227+
228+
/* ── Bento card subtle border-glow (Supabase-style) ───── */
229+
.bento-card {
230+
background:
231+
linear-gradient(180deg, rgba(255,255,255,0.02) 0%, rgba(255,255,255,0) 100%),
232+
#0c0c0f;
233+
}
234+
235+
.bento-card::before {
236+
content: '';
237+
position: absolute;
238+
inset: 0;
239+
border-radius: inherit;
240+
padding: 1px;
241+
background: linear-gradient(
242+
180deg,
243+
rgba(255, 255, 255, 0.06) 0%,
244+
rgba(255, 255, 255, 0.02) 50%,
245+
rgba(255, 255, 255, 0) 100%
246+
);
247+
-webkit-mask:
248+
linear-gradient(#fff 0 0) content-box,
249+
linear-gradient(#fff 0 0);
250+
-webkit-mask-composite: xor;
251+
mask-composite: exclude;
252+
pointer-events: none;
253+
}
254+
255+
.bento-card:hover {
256+
background:
257+
linear-gradient(180deg, rgba(255,255,255,0.035) 0%, rgba(255,255,255,0.005) 100%),
258+
#0c0c0f;
259+
}
260+
261+
/* ── How-it-works step number gradients ─────────────── */
262+
.how-step-number {
263+
background: linear-gradient(135deg, var(--color-brand-300), var(--color-brand-500));
264+
-webkit-background-clip: text;
265+
-webkit-text-fill-color: transparent;
266+
background-clip: text;
267+
}
268+
269+
.how-step-number-accent {
270+
background: linear-gradient(135deg, var(--color-accent-300), var(--color-accent-500));
271+
-webkit-background-clip: text;
272+
-webkit-text-fill-color: transparent;
273+
background-clip: text;
274+
}
275+
276+
.how-step-number-success {
277+
background: linear-gradient(135deg, var(--color-success-300), var(--color-success-500));
278+
-webkit-background-clip: text;
279+
-webkit-text-fill-color: transparent;
280+
background-clip: text;
281+
}

app/composables/useScrollFade.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
/**
2+
* useScrollFade — Intersection Observer composable for premium scroll-triggered animations.
3+
*
4+
* Returns a template ref. Attach it to any element with the `scroll-fade` CSS class.
5+
* When the element enters the viewport it receives the `is-visible` class, triggering
6+
* the CSS transition defined in main.css.
7+
*
8+
* Usage:
9+
* const el = useScrollFade()
10+
* <section ref="el" class="scroll-fade"> … </section>
11+
*/
12+
export function useScrollFade(options?: { threshold?: number; rootMargin?: string }) {
13+
const el = ref<HTMLElement | null>(null)
14+
15+
onMounted(() => {
16+
if (!el.value) return
17+
18+
const observer = new IntersectionObserver(
19+
(entries) => {
20+
for (const entry of entries) {
21+
if (entry.isIntersecting) {
22+
entry.target.classList.add('is-visible')
23+
observer.unobserve(entry.target) // animate once
24+
}
25+
}
26+
},
27+
{
28+
threshold: options?.threshold ?? 0.15,
29+
rootMargin: options?.rootMargin ?? '0px 0px -40px 0px',
30+
},
31+
)
32+
33+
observer.observe(el.value)
34+
35+
onUnmounted(() => observer.disconnect())
36+
})
37+
38+
return el
39+
}
40+
41+
/**
42+
* useScrollFadeAll — observe multiple elements at once.
43+
* Call `register(el)` in a v-for / template ref function.
44+
*/
45+
export function useScrollFadeAll(options?: { threshold?: number; rootMargin?: string }) {
46+
const elements: HTMLElement[] = []
47+
let observer: IntersectionObserver | null = null
48+
49+
function register(el: HTMLElement | ComponentPublicInstance | null) {
50+
if (!el) return
51+
const htmlEl = '$el' in el ? (el.$el as HTMLElement) : el
52+
if (htmlEl instanceof HTMLElement && !elements.includes(htmlEl)) {
53+
elements.push(htmlEl)
54+
observer?.observe(htmlEl)
55+
}
56+
}
57+
58+
onMounted(() => {
59+
observer = new IntersectionObserver(
60+
(entries) => {
61+
for (const entry of entries) {
62+
if (entry.isIntersecting) {
63+
entry.target.classList.add('is-visible')
64+
observer?.unobserve(entry.target)
65+
}
66+
}
67+
},
68+
{
69+
threshold: options?.threshold ?? 0.15,
70+
rootMargin: options?.rootMargin ?? '0px 0px -40px 0px',
71+
},
72+
)
73+
74+
for (const el of elements) observer.observe(el)
75+
76+
onUnmounted(() => observer?.disconnect())
77+
})
78+
79+
return { register }
80+
}

0 commit comments

Comments
 (0)