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.
- package/README.md +42 -9
- package/cli/uifolio.mjs +446 -221
- package/package.json +2 -1
- package/src/components/animations/animation-settings-context.ts +71 -0
- package/src/components/animations/animation-settings-provider.tsx +69 -0
- package/src/components/animations/data.ts +68 -0
- package/src/components/animations/horizontal-accordion.tsx +142 -0
- package/src/components/animations/horizontal-story.tsx +204 -0
- package/src/components/animations/image-crossfade.tsx +276 -0
- package/src/components/animations/palette-context.ts +24 -0
- package/src/components/animations/palette-provider.tsx +28 -0
- package/src/components/animations/palette.ts +18 -0
- package/src/components/animations/scroll-text.tsx +161 -0
- package/src/components/animations/stacking-cards.tsx +162 -0
|
@@ -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
|
+
}
|