uifolio 0.6.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,276 @@
1
+ import { useEffect, useRef, useState } from 'react'
2
+ import { useReducedMotion } from '@/lib/use-reduced-motion'
3
+
4
+ export interface CrossfadeImage {
5
+ src: string
6
+ alt: string
7
+ kicker: string
8
+ title: string
9
+ description: string
10
+ }
11
+
12
+ const defaultOverlay =
13
+ 'linear-gradient(to top, rgb(0 0 0 / 0.55), rgb(0 0 0 / 0.05), rgb(0 0 0 / 0.35))'
14
+
15
+ const defaultImages: CrossfadeImage[] = [
16
+ {
17
+ src: '/crossfade-1.svg',
18
+ alt: 'Hills at sunset, in amber and coral tones.',
19
+ kicker: '01 — Light',
20
+ title: 'Dawn',
21
+ description: 'The day settles over the reliefs, the colors warm up.',
22
+ },
23
+ {
24
+ src: '/crossfade-2.svg',
25
+ alt: 'Turquoise sea horizon under a pale moon.',
26
+ kicker: '02 — Tide',
27
+ title: 'Ocean',
28
+ description: 'A horizon line, two tones, and the motion that ties them together.',
29
+ },
30
+ {
31
+ src: '/crossfade-3.svg',
32
+ alt: 'Deep forest crossed by a green mist.',
33
+ kicker: '03 — Canopy',
34
+ title: 'Forest',
35
+ description: 'Silhouettes answer each other in filtered light.',
36
+ },
37
+ {
38
+ src: '/crossfade-4.svg',
39
+ alt: 'Nocturne mountains under an indigo sky and a clear moon.',
40
+ kicker: '04 — Night',
41
+ title: 'Night',
42
+ description: 'When the light drops, the outlines become secrets.',
43
+ },
44
+ ]
45
+
46
+ function clamp(value: number) {
47
+ return Math.min(1, Math.max(0, value))
48
+ }
49
+
50
+ function StaticGallery({ images }: { images: CrossfadeImage[] }) {
51
+ return (
52
+ <div className="grid gap-3 sm:grid-cols-2">
53
+ {images.map((image) => (
54
+ <figure key={image.src} className="overflow-hidden rounded-sm border bg-card">
55
+ <img
56
+ src={image.src}
57
+ alt={image.alt}
58
+ loading="lazy"
59
+ decoding="async"
60
+ className="aspect-[16/10] w-full object-cover"
61
+ />
62
+ <figcaption className="px-3 py-2.5">
63
+ <span className="text-[9px] font-semibold uppercase tracking-[0.16em] text-primary">
64
+ {image.kicker}
65
+ </span>
66
+ <p className="mt-0.5 text-sm font-semibold">{image.title}</p>
67
+ </figcaption>
68
+ </figure>
69
+ ))}
70
+ </div>
71
+ )
72
+ }
73
+
74
+ export interface ImageCrossfadeProps {
75
+ images?: CrossfadeImage[]
76
+ overlay?: string
77
+ delay?: number
78
+ duration?: number
79
+ easing?: string
80
+ }
81
+
82
+ export function ImageCrossfade({
83
+ images = defaultImages,
84
+ overlay = defaultOverlay,
85
+ delay = 0,
86
+ duration = 500,
87
+ easing = 'ease-out',
88
+ }: ImageCrossfadeProps) {
89
+ const list = images.length > 0 ? images : defaultImages
90
+ const section = useRef<HTMLDivElement>(null)
91
+ const [progress, setProgress] = useState(0)
92
+ const reduced = useReducedMotion()
93
+
94
+ useEffect(() => {
95
+ if (reduced) return
96
+
97
+ let frame = 0
98
+ const update = () => {
99
+ cancelAnimationFrame(frame)
100
+ frame = requestAnimationFrame(() => {
101
+ const element = section.current
102
+ if (!element) return
103
+ const total = element.offsetHeight - window.innerHeight + 57
104
+ const top = element.getBoundingClientRect().top - 57
105
+ setProgress(total > 0 ? Math.min(1, Math.max(0, -top / total)) : 0)
106
+ })
107
+ }
108
+
109
+ update()
110
+ window.addEventListener('scroll', update, { passive: true })
111
+ window.addEventListener('resize', update)
112
+ return () => {
113
+ window.removeEventListener('scroll', update)
114
+ window.removeEventListener('resize', update)
115
+ cancelAnimationFrame(frame)
116
+ }
117
+ }, [reduced])
118
+
119
+ if (reduced) return <StaticGallery images={list} />
120
+
121
+ const slideCount = list.length
122
+ const maxIndex = Math.max(1, slideCount - 1)
123
+ const position = slideCount <= 1 ? 0 : progress * maxIndex
124
+ const index = Math.min(slideCount - 1, Math.floor(position))
125
+ const blend = slideCount <= 1 ? 0 : position - index
126
+ const textT = clamp((blend - 0.44) / 0.12)
127
+ const textEased = textT * textT * (3 - 2 * textT)
128
+ const active = Math.min(slideCount - 1, Math.round(position))
129
+ const scale = 1.02 + progress * 0.04
130
+
131
+ const goTo = (target: number) => {
132
+ const element = section.current
133
+ if (!element) return
134
+ const total = element.offsetHeight - window.innerHeight + 57
135
+ const top = element.getBoundingClientRect().top + window.scrollY - 57
136
+ window.scrollTo({
137
+ top: top + total * (target / maxIndex),
138
+ behavior: 'smooth',
139
+ })
140
+ }
141
+
142
+ return (
143
+ <div
144
+ ref={section}
145
+ className="relative"
146
+ style={{ height: `${Math.max(2, slideCount) * 100}vh` }}
147
+ >
148
+ <div className="sticky top-[57px] flex h-[calc(100dvh-57px)] items-center justify-center px-0 py-2 sm:px-4 sm:py-4">
149
+ <div className="relative flex h-[min(calc(100dvh-85px),680px)] w-full max-w-5xl flex-col overflow-hidden rounded-sm border bg-card shadow-xl sm:flex-row">
150
+ <div className="relative h-[42%] w-full shrink-0 overflow-hidden bg-muted sm:h-auto sm:min-h-0 sm:flex-1">
151
+ {list.map((image, imageIndex) => {
152
+ const isCurrent = slideCount > 1 && imageIndex === index
153
+ const isNext = slideCount > 1 && imageIndex === index + 1
154
+ const visible = slideCount <= 1 ? imageIndex === 0 : isCurrent || isNext
155
+ return (
156
+ <img
157
+ key={image.src}
158
+ src={image.src}
159
+ alt={image.alt}
160
+ loading="eager"
161
+ decoding="async"
162
+ draggable={false}
163
+ aria-hidden={imageIndex !== active}
164
+ data-image-slide
165
+ className="absolute inset-0 h-full w-full object-cover will-change-[clip-path,transform]"
166
+ style={{
167
+ opacity: visible ? 1 : 0,
168
+ zIndex: isCurrent ? 2 : isNext ? 1 : 0,
169
+ clipPath: isCurrent ? `inset(0 0 ${blend * 100}% 0)` : undefined,
170
+ transform: `scale(${scale})`,
171
+ transitionProperty: 'opacity',
172
+ transitionDelay: `${delay}ms`,
173
+ transitionDuration: `${duration}ms`,
174
+ transitionTimingFunction: easing,
175
+ }}
176
+ />
177
+ )
178
+ })}
179
+
180
+ <div
181
+ aria-hidden
182
+ className="pointer-events-none absolute inset-0"
183
+ style={{ background: overlay }}
184
+ />
185
+
186
+ <div className="absolute inset-x-0 top-0 flex items-center justify-between gap-3 px-4 py-3 text-white sm:px-5">
187
+ <span className="text-[9px] font-semibold uppercase tracking-[0.2em] text-white/80">
188
+ Images fondues
189
+ </span>
190
+ <span className="font-mono text-[9px] uppercase tracking-[0.18em] text-white/80">
191
+ {String(active + 1).padStart(2, '0')} / {String(slideCount).padStart(2, '0')}
192
+ </span>
193
+ </div>
194
+ </div>
195
+
196
+ <div className="relative flex min-h-0 flex-1 flex-col justify-between border-t bg-card p-5 sm:w-[43%] sm:flex-none sm:border-l sm:border-t-0 sm:p-7">
197
+ <div className="relative min-h-0 flex-1">
198
+ {list.map((image, imageIndex) => {
199
+ const opacity =
200
+ slideCount <= 1
201
+ ? imageIndex === 0
202
+ ? 1
203
+ : 0
204
+ : imageIndex === index
205
+ ? 1 - textEased
206
+ : imageIndex === index + 1
207
+ ? textEased
208
+ : 0
209
+ return (
210
+ <div
211
+ key={image.src}
212
+ data-text-slide
213
+ data-active={imageIndex === active ? 'true' : undefined}
214
+ aria-hidden={imageIndex !== active}
215
+ className="pointer-events-none absolute inset-0 flex flex-col justify-center"
216
+ style={{
217
+ opacity,
218
+ zIndex: imageIndex === index ? 2 : imageIndex === index + 1 ? 1 : 0,
219
+ transitionProperty: 'opacity',
220
+ transitionDelay: `${delay}ms`,
221
+ transitionDuration: `${duration}ms`,
222
+ transitionTimingFunction: easing,
223
+ }}
224
+ >
225
+ <p className="text-[9px] font-semibold uppercase tracking-[0.2em] text-primary">
226
+ {image.kicker}
227
+ </p>
228
+ <h3 className="mt-2 text-[clamp(1.6rem,3vw,3rem)] font-semibold leading-none tracking-tight">
229
+ {image.title}
230
+ </h3>
231
+ <p className="mt-3 max-w-sm text-[12px] leading-relaxed text-muted-foreground sm:text-[13px]">
232
+ {image.description}
233
+ </p>
234
+ </div>
235
+ )
236
+ })}
237
+ </div>
238
+
239
+ <div className="relative z-10 mt-4 flex items-center justify-between gap-3">
240
+ <div className="flex items-center gap-1.5">
241
+ {list.map((image, imageIndex) => (
242
+ <button
243
+ key={image.src}
244
+ type="button"
245
+ onClick={() => goTo(imageIndex)}
246
+ aria-label={`Afficher ${image.title}`}
247
+ aria-current={imageIndex === active ? 'true' : undefined}
248
+ className={`h-1.5 rounded-full transition-all ${
249
+ imageIndex === active
250
+ ? 'w-7 bg-primary'
251
+ : 'w-3 bg-border hover:bg-muted-foreground/60'
252
+ }`}
253
+ />
254
+ ))}
255
+ </div>
256
+ <span className="font-mono text-[9px] uppercase tracking-[0.18em] text-muted-foreground">
257
+ {String(active + 1).padStart(2, '0')} / {String(slideCount).padStart(2, '0')}
258
+ </span>
259
+ </div>
260
+
261
+ <div
262
+ aria-hidden
263
+ className="absolute inset-y-8 left-0 hidden w-px overflow-hidden bg-border sm:block"
264
+ >
265
+ <div className="w-px bg-primary" style={{ height: `${progress * 100}%` }} />
266
+ </div>
267
+ </div>
268
+
269
+ <div aria-hidden className="absolute inset-x-0 bottom-0 h-px overflow-hidden bg-border">
270
+ <div className="h-px bg-primary" style={{ width: `${progress * 100}%` }} />
271
+ </div>
272
+ </div>
273
+ </div>
274
+ </div>
275
+ )
276
+ }
@@ -0,0 +1,24 @@
1
+ import { createContext, useContext } from 'react'
2
+ import type { CardPalette } from './palette'
3
+
4
+ export type CardPaletteMode = 'theme' | 'gradient' | 'solid'
5
+
6
+ export interface CardPaletteContextValue {
7
+ mode: CardPaletteMode
8
+ from: string
9
+ to: string
10
+ solid: string
11
+ palette: CardPalette | undefined
12
+ setMode: (mode: CardPaletteMode) => void
13
+ setFrom: (color: string) => void
14
+ setTo: (color: string) => void
15
+ setSolid: (color: string) => void
16
+ }
17
+
18
+ export const CardPaletteContext = createContext<CardPaletteContextValue | null>(null)
19
+
20
+ export function useCardPalette() {
21
+ const context = useContext(CardPaletteContext)
22
+ if (!context) throw new Error('useCardPalette must be used within CardPaletteProvider')
23
+ return context
24
+ }
@@ -0,0 +1,28 @@
1
+ import { useState, type ReactNode } from 'react'
2
+ import type { CardPalette } from './palette'
3
+ import {
4
+ CardPaletteContext,
5
+ type CardPaletteMode,
6
+ } from './palette-context'
7
+
8
+ export function CardPaletteProvider({ children }: { children: ReactNode }) {
9
+ const [mode, setMode] = useState<CardPaletteMode>('theme')
10
+ const [from, setFrom] = useState('#0EA5E9')
11
+ const [to, setTo] = useState('#8B5CF6')
12
+ const [solid, setSolid] = useState('#10B981')
13
+
14
+ const palette: CardPalette | undefined =
15
+ mode === 'theme'
16
+ ? undefined
17
+ : mode === 'gradient'
18
+ ? { mode: 'gradient', from, to }
19
+ : { mode: 'solid', color: solid }
20
+
21
+ return (
22
+ <CardPaletteContext.Provider
23
+ value={{ mode, from, to, solid, palette, setMode, setFrom, setTo, setSolid }}
24
+ >
25
+ {children}
26
+ </CardPaletteContext.Provider>
27
+ )
28
+ }
@@ -0,0 +1,18 @@
1
+ export type CardPalette =
2
+ | { mode: 'gradient'; from: string; to: string }
3
+ | { mode: 'solid'; color: string }
4
+
5
+ export function cardBackground(palette: CardPalette | undefined, fallback: string) {
6
+ if (!palette) return fallback
7
+ if (palette.mode === 'solid') return palette.color
8
+ return `linear-gradient(135deg, ${palette.from} 0%, ${palette.to} 100%)`
9
+ }
10
+
11
+ export function paletteAura(palette: CardPalette | undefined) {
12
+ if (palette?.mode === 'solid') return 'none'
13
+
14
+ const from = palette?.from ?? 'var(--primary)'
15
+ const to = palette?.to ?? 'var(--primary)'
16
+
17
+ return `radial-gradient(circle at 20% 30%, color-mix(in oklab, ${from} 20%, transparent), transparent 45%), radial-gradient(circle at 85% 70%, color-mix(in oklab, ${to} 12%, transparent), transparent 40%)`
18
+ }
@@ -0,0 +1,161 @@
1
+ import { Fragment, useEffect, useRef, useState } from 'react'
2
+ import type { RefObject } from 'react'
3
+ import { useReducedMotion } from '@/lib/use-reduced-motion'
4
+
5
+ export interface ScrollTextProps {
6
+ text?: string
7
+ prefix?: string
8
+ className?: string
9
+ delay?: number
10
+ duration?: number
11
+ easing?: string
12
+ }
13
+
14
+ const REVEAL_START_RATIO = 0.9
15
+ const REVEAL_END_RATIO = 0.3
16
+
17
+ function wordOffsets(words: string[]) {
18
+ let total = 0
19
+ return words.map((word) => {
20
+ const start = total
21
+ total += Array.from(word).length
22
+ return start
23
+ })
24
+ }
25
+
26
+ function Paragraph({
27
+ words,
28
+ visibleLetters,
29
+ paragraphRef,
30
+ className,
31
+ delay = 0,
32
+ duration = 300,
33
+ easing = 'ease-out',
34
+ }: {
35
+ words: string[]
36
+ visibleLetters: number
37
+ paragraphRef?: RefObject<HTMLParagraphElement | null>
38
+ className: string
39
+ delay?: number
40
+ duration?: number
41
+ easing?: string
42
+ }) {
43
+ const lettersByWord = words.map((word) => Array.from(word))
44
+ const offsets = wordOffsets(words)
45
+
46
+ return (
47
+ <p ref={paragraphRef} data-scroll-text className={className}>
48
+ <span className="sr-only">{words.join(' ')}</span>
49
+ <span aria-hidden="true">
50
+ {lettersByWord.map((letters, wordIndex) => {
51
+ const start = offsets[wordIndex]
52
+
53
+ return (
54
+ <Fragment key={`${words[wordIndex]}-${wordIndex}`}>
55
+ <span data-word className="inline-block whitespace-nowrap">
56
+ {letters.map((letter, index) => {
57
+ const lit = start + index < visibleLetters
58
+ return (
59
+ <span
60
+ key={`${letter}-${index}`}
61
+ data-letter
62
+ className={`transition-colors ${
63
+ lit ? 'text-primary' : 'text-muted-foreground'
64
+ }`}
65
+ style={{
66
+ transitionDelay: `${delay}ms`,
67
+ transitionDuration: `${duration}ms`,
68
+ transitionTimingFunction: easing,
69
+ }}
70
+ >
71
+ {letter}
72
+ </span>
73
+ )
74
+ })}
75
+ </span>
76
+ {wordIndex < words.length - 1 ? ' ' : null}
77
+ </Fragment>
78
+ )
79
+ })}
80
+ </span>
81
+ </p>
82
+ )
83
+ }
84
+
85
+ export function ScrollText({
86
+ text = 'Each pass lights the text a bit more. Scroll up, and the color gradually returns to its initial state.',
87
+ prefix = 'C',
88
+ className = 'w-full max-w-3xl text-[clamp(1.3rem,3.2vw,2.4rem)] font-medium leading-[1.35] tracking-tight',
89
+ delay = 0,
90
+ duration = 300,
91
+ easing = 'ease-out',
92
+ }: ScrollTextProps) {
93
+ const words = text.trim() ? text.trim().split(/\s+/) : []
94
+ const letterCount = words.reduce((total, word) => total + Array.from(word).length, 0)
95
+ const prefixLetters = Array.from(prefix.replace(/\s+/g, '')).length
96
+ const [visibleLetters, setVisibleLetters] = useState(0)
97
+ const paragraphRef = useRef<HTMLParagraphElement>(null)
98
+ const reduced = useReducedMotion()
99
+
100
+ useEffect(() => {
101
+ if (reduced) return
102
+
103
+ const element = paragraphRef.current
104
+ if (!element) return
105
+
106
+ let frame = 0
107
+ const paint = () => {
108
+ frame = 0
109
+ const { top } = element.getBoundingClientRect()
110
+ const viewport = window.innerHeight || 1
111
+ const start = viewport * REVEAL_START_RATIO
112
+ const end = viewport * REVEAL_END_RATIO
113
+ const progress = Math.min(1, Math.max(0, (top - end) / (start - end)))
114
+ const revealed = Math.max(prefixLetters, Math.round((1 - progress) * letterCount))
115
+ setVisibleLetters(revealed)
116
+ }
117
+
118
+ const schedulePaint = () => {
119
+ if (frame) return
120
+ frame = requestAnimationFrame(paint)
121
+ }
122
+
123
+ schedulePaint()
124
+ window.addEventListener('scroll', schedulePaint, { passive: true })
125
+ window.addEventListener('resize', schedulePaint)
126
+ return () => {
127
+ window.removeEventListener('scroll', schedulePaint)
128
+ window.removeEventListener('resize', schedulePaint)
129
+ cancelAnimationFrame(frame)
130
+ }
131
+ }, [reduced, prefixLetters, letterCount])
132
+
133
+ if (reduced) {
134
+ return (
135
+ <div className="flex min-h-[60vh] items-center justify-center px-5 py-16 sm:px-8">
136
+ <Paragraph
137
+ words={words}
138
+ visibleLetters={letterCount}
139
+ className={className}
140
+ delay={delay}
141
+ duration={duration}
142
+ easing={easing}
143
+ />
144
+ </div>
145
+ )
146
+ }
147
+
148
+ return (
149
+ <div className="flex min-h-[100vh] items-center justify-center px-5 py-16 sm:px-8">
150
+ <Paragraph
151
+ words={words}
152
+ visibleLetters={visibleLetters}
153
+ paragraphRef={paragraphRef}
154
+ className={className}
155
+ delay={delay}
156
+ duration={duration}
157
+ easing={easing}
158
+ />
159
+ </div>
160
+ )
161
+ }
@@ -0,0 +1,162 @@
1
+ import { useEffect, useRef, useState } from 'react'
2
+ import { ArrowDown } from 'lucide-react'
3
+ import { useReducedMotion } from '@/lib/use-reduced-motion'
4
+ import { cn } from '@/lib/utils'
5
+ import { makeMotionCards, type MotionCardData } from './data'
6
+ import { cardBackground, type CardPalette } from './palette'
7
+
8
+ function CardContent({ card, flat = false }: { card: MotionCardData; flat?: boolean }) {
9
+ return (
10
+ <>
11
+ {!flat && <span data-card-scrim className="absolute inset-0 bg-black/30" />}
12
+ <span className="relative flex items-start justify-between gap-4 text-white/65">
13
+ <span className="text-[9px] font-semibold uppercase tracking-[0.22em]">
14
+ Chapitre {card.number} · {card.kicker}
15
+ </span>
16
+ <span className="font-mono text-5xl font-black leading-none tracking-tighter text-white/15 sm:text-7xl">
17
+ {card.number}
18
+ </span>
19
+ </span>
20
+ <span className="relative block">
21
+ <span className="display block text-4xl leading-none text-white sm:text-6xl">
22
+ {card.title}
23
+ </span>
24
+ <span className="mt-3 block max-w-md text-[12px] leading-relaxed text-white/70">
25
+ {card.description}
26
+ </span>
27
+ <span className="mt-5 inline-flex items-center gap-2 text-[9px] font-semibold uppercase tracking-[0.2em] text-white/50">
28
+ <ArrowDown className="size-3" />
29
+ Empiler
30
+ </span>
31
+ </span>
32
+ </>
33
+ )
34
+ }
35
+
36
+ function StaticStack({ cards, flat = false }: { cards: MotionCardData[]; flat?: boolean }) {
37
+ return (
38
+ <div className="grid gap-3">
39
+ {cards.map((card) => (
40
+ <article
41
+ key={card.id}
42
+ className="relative flex h-[300px] flex-col justify-between overflow-hidden rounded-sm border border-border p-5 shadow-sm sm:h-[340px] sm:p-6"
43
+ style={{ background: card.gradient }}
44
+ data-motion-card
45
+ >
46
+ <CardContent card={card} flat={flat} />
47
+ </article>
48
+ ))}
49
+ </div>
50
+ )
51
+ }
52
+
53
+ export interface StackingCardsProps {
54
+ count?: number
55
+ palette?: CardPalette
56
+ delay?: number
57
+ duration?: number
58
+ easing?: string
59
+ }
60
+
61
+ export function StackingCards({
62
+ count = 6,
63
+ palette,
64
+ delay = 0,
65
+ duration = 300,
66
+ easing = 'ease-out',
67
+ }: StackingCardsProps) {
68
+ const cards = makeMotionCards(count).map((card) => ({
69
+ ...card,
70
+ gradient: cardBackground(palette, card.gradient),
71
+ }))
72
+ const flat = palette?.mode === 'solid'
73
+ const section = useRef<HTMLDivElement>(null)
74
+ const [progress, setProgress] = useState(0)
75
+ const reduced = useReducedMotion()
76
+
77
+ useEffect(() => {
78
+ if (reduced) return
79
+
80
+ let frame = 0
81
+ const update = () => {
82
+ cancelAnimationFrame(frame)
83
+ frame = requestAnimationFrame(() => {
84
+ const element = section.current
85
+ if (!element) return
86
+ const total = element.offsetHeight - window.innerHeight + 57
87
+ const top = element.getBoundingClientRect().top - 57
88
+ setProgress(total > 0 ? Math.min(1, Math.max(0, -top / total)) : 0)
89
+ })
90
+ }
91
+
92
+ update()
93
+ window.addEventListener('scroll', update, { passive: true })
94
+ window.addEventListener('resize', update)
95
+ return () => {
96
+ window.removeEventListener('scroll', update)
97
+ window.removeEventListener('resize', update)
98
+ cancelAnimationFrame(frame)
99
+ }
100
+ }, [count, reduced])
101
+
102
+ if (reduced) return <StaticStack cards={cards} flat={flat} />
103
+
104
+ const active = Math.min(cards.length - 1, Math.floor(progress * cards.length))
105
+ const tint = `color-mix(in oklab, var(--primary) ${Math.round(progress * 100)}%, var(--muted-foreground))`
106
+
107
+ return (
108
+ <>
109
+ <div ref={section} className="relative hidden md:block">
110
+ <div
111
+ className="sticky top-[57px] z-40 flex items-center justify-between gap-3 border-b bg-background/90 px-4 py-2.5 backdrop-blur-sm transition-colors"
112
+ style={{
113
+ color: tint,
114
+ transitionDelay: `${delay}ms`,
115
+ transitionDuration: `${duration}ms`,
116
+ transitionTimingFunction: easing,
117
+ }}
118
+ >
119
+ <span className="inline-flex items-center gap-2 font-mono text-[10px] font-semibold uppercase tracking-[0.2em]">
120
+ <ArrowDown className="size-3" />
121
+ Scroll pour empiler
122
+ </span>
123
+ <span className="font-mono text-[10px] font-semibold uppercase tracking-[0.2em]">
124
+ {Math.round(progress * 100)}% · {String(active + 1).padStart(2, '0')} /{' '}
125
+ {String(cards.length).padStart(2, '0')}
126
+ </span>
127
+ </div>
128
+
129
+ <div className="relative px-3 pt-5 after:block after:h-[60vh] after:content-[''] sm:px-5">
130
+ {cards.map((card, index) => (
131
+ <article
132
+ key={card.id}
133
+ aria-current={index === active ? 'step' : undefined}
134
+ className={cn(
135
+ 'sticky mx-auto mb-[12vh] flex h-[min(64vh,520px)] w-full max-w-3xl flex-col justify-between overflow-hidden rounded-sm border p-5 shadow-2xl transition-[border-color] last:mb-0 sm:p-7',
136
+ index === active ? 'border-primary/50' : 'border-border',
137
+ )}
138
+ data-motion-card
139
+ style={{
140
+ top: `calc(57px + 44px + ${index * 18}px)`,
141
+ zIndex: index + 1,
142
+ background: card.gradient,
143
+ transitionDelay: `${delay}ms`,
144
+ transitionDuration: `${duration}ms`,
145
+ transitionTimingFunction: easing,
146
+ }}
147
+ >
148
+ <CardContent card={card} flat={flat} />
149
+ </article>
150
+ ))}
151
+ </div>
152
+ </div>
153
+
154
+ <div className="md:hidden">
155
+ <StaticStack cards={cards} />
156
+ <p className="mt-2 text-[10px] text-muted-foreground">
157
+ Mobile version: cards stay stacked vertically.
158
+ </p>
159
+ </div>
160
+ </>
161
+ )
162
+ }