uifolio 0.5.5 → 0.7.0
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 +41 -8
- package/cli/uifolio.mjs +465 -51
- 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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "uifolio",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "CLI that copies React components (calendar, video/audio players, OTP, editor, upload) and their helpers into your project, with Tailwind CSS 4 tokens.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -55,6 +55,7 @@
|
|
|
55
55
|
"cli",
|
|
56
56
|
"src/components/ui",
|
|
57
57
|
"!src/components/ui/stories-tokens.ts",
|
|
58
|
+
"src/components/animations",
|
|
58
59
|
"src/lib/color.ts",
|
|
59
60
|
"src/lib/date.ts",
|
|
60
61
|
"src/lib/uploader.ts",
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { createContext, useContext } from 'react'
|
|
2
|
+
|
|
3
|
+
export interface AnimationSettings {
|
|
4
|
+
delay: number
|
|
5
|
+
duration: number
|
|
6
|
+
easing: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface AnimationSettingsContextValue extends AnimationSettings {
|
|
10
|
+
setDelay: (value: number) => void
|
|
11
|
+
setDuration: (value: number) => void
|
|
12
|
+
setEasing: (value: string) => void
|
|
13
|
+
reset: () => void
|
|
14
|
+
isCustomized: boolean
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const EASING_OPTIONS = [
|
|
18
|
+
{ value: 'ease-out', label: 'Soft' },
|
|
19
|
+
{ value: 'ease-in-out', label: 'Smooth' },
|
|
20
|
+
{ value: 'cubic-bezier(0.65, 0, 0.35, 1)', label: 'Spring' },
|
|
21
|
+
{ value: 'cubic-bezier(0.34, 1.56, 0.64, 1)', label: 'Bounce' },
|
|
22
|
+
{ value: 'linear', label: 'Linear' },
|
|
23
|
+
] as const
|
|
24
|
+
|
|
25
|
+
const fallback: AnimationSettings = {
|
|
26
|
+
delay: 0,
|
|
27
|
+
duration: 500,
|
|
28
|
+
easing: 'ease-out',
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const animationDefaults: Record<string, AnimationSettings> = {
|
|
32
|
+
'horizontal-accordion': {
|
|
33
|
+
delay: 0,
|
|
34
|
+
duration: 500,
|
|
35
|
+
easing: 'cubic-bezier(0.65, 0, 0.35, 1)',
|
|
36
|
+
},
|
|
37
|
+
storytelling: {
|
|
38
|
+
delay: 0,
|
|
39
|
+
duration: 500,
|
|
40
|
+
easing: 'cubic-bezier(0.65, 0, 0.35, 1)',
|
|
41
|
+
},
|
|
42
|
+
stacking: {
|
|
43
|
+
delay: 0,
|
|
44
|
+
duration: 300,
|
|
45
|
+
easing: 'ease-out',
|
|
46
|
+
},
|
|
47
|
+
'scroll-text': {
|
|
48
|
+
delay: 0,
|
|
49
|
+
duration: 300,
|
|
50
|
+
easing: 'ease-out',
|
|
51
|
+
},
|
|
52
|
+
'image-crossfade': {
|
|
53
|
+
delay: 0,
|
|
54
|
+
duration: 500,
|
|
55
|
+
easing: 'ease-out',
|
|
56
|
+
},
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function defaultAnimationSettings(slug?: string): AnimationSettings {
|
|
60
|
+
return (slug && animationDefaults[slug]) || fallback
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export const AnimationSettingsContext = createContext<AnimationSettingsContextValue | null>(null)
|
|
64
|
+
|
|
65
|
+
export function useAnimationSettings() {
|
|
66
|
+
const context = useContext(AnimationSettingsContext)
|
|
67
|
+
if (!context) {
|
|
68
|
+
throw new Error('useAnimationSettings must be used within AnimationSettingsProvider')
|
|
69
|
+
}
|
|
70
|
+
return context
|
|
71
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { useCallback, useMemo, useState } from 'react'
|
|
2
|
+
import type { ReactNode } from 'react'
|
|
3
|
+
import {
|
|
4
|
+
AnimationSettingsContext,
|
|
5
|
+
defaultAnimationSettings,
|
|
6
|
+
type AnimationSettings,
|
|
7
|
+
} from './animation-settings-context'
|
|
8
|
+
|
|
9
|
+
export function AnimationSettingsProvider({
|
|
10
|
+
current,
|
|
11
|
+
children,
|
|
12
|
+
}: {
|
|
13
|
+
current?: string
|
|
14
|
+
children: ReactNode
|
|
15
|
+
}) {
|
|
16
|
+
const key = current ?? '__default'
|
|
17
|
+
const [settingsBySlug, setSettingsBySlug] = useState<Record<string, AnimationSettings>>({})
|
|
18
|
+
|
|
19
|
+
const settings = settingsBySlug[key] ?? defaultAnimationSettings(current)
|
|
20
|
+
|
|
21
|
+
const update = useCallback(
|
|
22
|
+
(patch: Partial<AnimationSettings>) => {
|
|
23
|
+
setSettingsBySlug((previous) => ({
|
|
24
|
+
...previous,
|
|
25
|
+
[key]: {
|
|
26
|
+
...(previous[key] ?? defaultAnimationSettings(current)),
|
|
27
|
+
...patch,
|
|
28
|
+
},
|
|
29
|
+
}))
|
|
30
|
+
},
|
|
31
|
+
[current, key],
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
const setDelay = useCallback(
|
|
35
|
+
(delay: number) => update({ delay: Math.max(0, Math.min(2000, delay)) }),
|
|
36
|
+
[update],
|
|
37
|
+
)
|
|
38
|
+
const setDuration = useCallback(
|
|
39
|
+
(duration: number) => update({ duration: Math.max(100, Math.min(3000, duration)) }),
|
|
40
|
+
[update],
|
|
41
|
+
)
|
|
42
|
+
const setEasing = useCallback((easing: string) => update({ easing }), [update])
|
|
43
|
+
|
|
44
|
+
const reset = useCallback(() => {
|
|
45
|
+
setSettingsBySlug((previous) => {
|
|
46
|
+
const next = { ...previous }
|
|
47
|
+
delete next[key]
|
|
48
|
+
return next
|
|
49
|
+
})
|
|
50
|
+
}, [key])
|
|
51
|
+
|
|
52
|
+
const value = useMemo(
|
|
53
|
+
() => ({
|
|
54
|
+
...settings,
|
|
55
|
+
setDelay,
|
|
56
|
+
setDuration,
|
|
57
|
+
setEasing,
|
|
58
|
+
reset,
|
|
59
|
+
isCustomized: Boolean(settingsBySlug[key]),
|
|
60
|
+
}),
|
|
61
|
+
[reset, setDelay, setDuration, setEasing, settings, settingsBySlug, key],
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<AnimationSettingsContext.Provider value={value}>
|
|
66
|
+
{children}
|
|
67
|
+
</AnimationSettingsContext.Provider>
|
|
68
|
+
)
|
|
69
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
export interface MotionCardData {
|
|
2
|
+
id: string
|
|
3
|
+
number: string
|
|
4
|
+
title: string
|
|
5
|
+
kicker: string
|
|
6
|
+
description: string
|
|
7
|
+
gradient: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const seeds: Omit<MotionCardData, 'id' | 'number'>[] = [
|
|
11
|
+
{
|
|
12
|
+
title: 'Explore',
|
|
13
|
+
kicker: 'Field',
|
|
14
|
+
description: 'Spot the signals, collect the material and open up the range of possibilities.',
|
|
15
|
+
gradient: 'linear-gradient(135deg, color-mix(in oklab, var(--primary) 22%, #000) 0%, var(--primary) 58%, color-mix(in oklab, var(--primary) 55%, #fff) 100%)',
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
title: 'Compose',
|
|
19
|
+
kicker: 'Workshop',
|
|
20
|
+
description: 'Assemble the pieces, test rhythms and let the structure breathe.',
|
|
21
|
+
gradient: 'linear-gradient(145deg, color-mix(in oklab, var(--primary) 38%, #000) 0%, color-mix(in oklab, var(--primary) 85%, #000) 52%, color-mix(in oklab, var(--primary) 62%, #fff) 100%)',
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
title: 'Publish',
|
|
25
|
+
kicker: 'Release',
|
|
26
|
+
description: 'Shape it, tell it and share a clear version to read and to live with.',
|
|
27
|
+
gradient: 'linear-gradient(125deg, color-mix(in oklab, var(--primary) 15%, #000) 0%, color-mix(in oklab, var(--primary) 70%, #000) 55%, color-mix(in oklab, var(--primary) 35%, #fff) 100%)',
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
title: 'Measure',
|
|
31
|
+
kicker: 'Reading',
|
|
32
|
+
description: 'Watch the usage, compare the signals and keep what truly makes sense.',
|
|
33
|
+
gradient: 'linear-gradient(155deg, color-mix(in oklab, var(--primary) 30%, #000) 0%, var(--primary) 60%, color-mix(in oklab, var(--primary) 75%, #fff) 100%)',
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
title: 'Iterate',
|
|
37
|
+
kicker: 'Loop',
|
|
38
|
+
description: 'Fix, refine, start again: every pass tightens the gesture and the message.',
|
|
39
|
+
gradient: 'linear-gradient(120deg, color-mix(in oklab, var(--primary) 12%, #000) 0%, color-mix(in oklab, var(--primary) 60%, #000) 50%, color-mix(in oklab, var(--primary) 25%, #fff) 100%)',
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
title: 'Spread',
|
|
43
|
+
kicker: 'Network',
|
|
44
|
+
description: 'Let the material circulate, build bridges and let the project spread.',
|
|
45
|
+
gradient: 'linear-gradient(135deg, color-mix(in oklab, var(--primary) 25%, #000) 0%, color-mix(in oklab, var(--primary) 90%, #000) 55%, color-mix(in oklab, var(--primary) 50%, #fff) 100%)',
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
title: 'Archive',
|
|
49
|
+
kicker: 'Memory',
|
|
50
|
+
description: 'Keep the traces, document the choices and make the work pass on.',
|
|
51
|
+
gradient: 'linear-gradient(150deg, color-mix(in oklab, var(--primary) 45%, #000) 0%, color-mix(in oklab, var(--primary) 75%, #000) 50%, color-mix(in oklab, var(--primary) 40%, #fff) 100%)',
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
title: 'Start over',
|
|
55
|
+
kicker: 'Cycle',
|
|
56
|
+
description: 'Set off again with what you learned, without losing the momentum or the precision of the first gesture.',
|
|
57
|
+
gradient: 'linear-gradient(130deg, color-mix(in oklab, var(--primary) 18%, #000) 0%, var(--primary) 55%, color-mix(in oklab, var(--primary) 70%, #fff) 100%)',
|
|
58
|
+
},
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
export function makeMotionCards(count: number): MotionCardData[] {
|
|
62
|
+
const total = Math.max(1, Math.min(count, seeds.length))
|
|
63
|
+
return seeds.slice(0, total).map((seed, index) => ({
|
|
64
|
+
...seed,
|
|
65
|
+
id: `${index}-${seed.title.toLowerCase()}`,
|
|
66
|
+
number: String(index + 1).padStart(2, '0'),
|
|
67
|
+
}))
|
|
68
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { useRef, useState } from 'react'
|
|
2
|
+
import type { KeyboardEvent } from 'react'
|
|
3
|
+
import { ArrowRight } from 'lucide-react'
|
|
4
|
+
import { cn } from '@/lib/utils'
|
|
5
|
+
import { makeMotionCards } from './data'
|
|
6
|
+
|
|
7
|
+
export interface HorizontalAccordionProps {
|
|
8
|
+
count?: number
|
|
9
|
+
delay?: number
|
|
10
|
+
duration?: number
|
|
11
|
+
easing?: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function HorizontalAccordion({
|
|
15
|
+
count = 6,
|
|
16
|
+
delay = 0,
|
|
17
|
+
duration = 500,
|
|
18
|
+
easing = 'cubic-bezier(0.65, 0, 0.35, 1)',
|
|
19
|
+
}: HorizontalAccordionProps) {
|
|
20
|
+
const cards = makeMotionCards(count)
|
|
21
|
+
const [active, setActive] = useState(0)
|
|
22
|
+
const current = Math.min(active, cards.length - 1)
|
|
23
|
+
const compact = count > 5
|
|
24
|
+
const buttons = useRef<Array<HTMLButtonElement | null>>([])
|
|
25
|
+
|
|
26
|
+
const go = (index: number) => {
|
|
27
|
+
const next = (index + cards.length) % cards.length
|
|
28
|
+
setActive(next)
|
|
29
|
+
buttons.current[next]?.focus()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const handleKeyDown = (index: number, event: KeyboardEvent<HTMLButtonElement>) => {
|
|
33
|
+
if (event.key === 'ArrowRight') {
|
|
34
|
+
event.preventDefault()
|
|
35
|
+
go(index + 1)
|
|
36
|
+
}
|
|
37
|
+
if (event.key === 'ArrowLeft') {
|
|
38
|
+
event.preventDefault()
|
|
39
|
+
go(index - 1)
|
|
40
|
+
}
|
|
41
|
+
if (event.key === 'Home') {
|
|
42
|
+
event.preventDefault()
|
|
43
|
+
go(0)
|
|
44
|
+
}
|
|
45
|
+
if (event.key === 'End') {
|
|
46
|
+
event.preventDefault()
|
|
47
|
+
go(cards.length - 1)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<div className="flex h-[400px] gap-1.5 overflow-hidden rounded-sm border bg-card/60 p-1.5 sm:h-[460px] sm:gap-2 sm:p-2">
|
|
53
|
+
{cards.map((card, index) => {
|
|
54
|
+
const isActive = index === current
|
|
55
|
+
return (
|
|
56
|
+
<button
|
|
57
|
+
key={card.id}
|
|
58
|
+
ref={(element) => {
|
|
59
|
+
buttons.current[index] = element
|
|
60
|
+
}}
|
|
61
|
+
type="button"
|
|
62
|
+
onClick={() => setActive(index)}
|
|
63
|
+
onPointerEnter={(event) => {
|
|
64
|
+
if (event.pointerType === 'mouse') setActive(index)
|
|
65
|
+
}}
|
|
66
|
+
onFocus={() => setActive(index)}
|
|
67
|
+
onKeyDown={(event) => handleKeyDown(index, event)}
|
|
68
|
+
aria-pressed={isActive}
|
|
69
|
+
aria-label={card.title}
|
|
70
|
+
tabIndex={isActive ? 0 : -1}
|
|
71
|
+
style={{
|
|
72
|
+
transitionDelay: `${delay}ms`,
|
|
73
|
+
transitionDuration: `${duration}ms`,
|
|
74
|
+
transitionTimingFunction: easing,
|
|
75
|
+
}}
|
|
76
|
+
className={cn(
|
|
77
|
+
'relative min-w-0 basis-0 overflow-hidden rounded-sm border text-left outline-none transition-[flex-grow,background-color,color] focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-inset',
|
|
78
|
+
isActive
|
|
79
|
+
? compact
|
|
80
|
+
? 'grow-[4.2] border-primary/50 bg-primary text-primary-foreground shadow-lg max-sm:grow-[6.2]'
|
|
81
|
+
: 'grow-[4.2] border-primary/50 bg-primary text-primary-foreground shadow-lg'
|
|
82
|
+
: compact
|
|
83
|
+
? 'grow-[0.7] border-border bg-muted/30 text-muted-foreground hover:bg-muted hover:text-foreground max-sm:grow-[0.3]'
|
|
84
|
+
: 'grow-[0.7] border-border bg-muted/30 text-muted-foreground hover:bg-muted hover:text-foreground',
|
|
85
|
+
)}
|
|
86
|
+
>
|
|
87
|
+
<span
|
|
88
|
+
className={cn(
|
|
89
|
+
'absolute right-2 top-1 font-mono text-5xl font-black leading-none tracking-tighter transition-opacity sm:text-7xl',
|
|
90
|
+
isActive ? 'opacity-20' : 'opacity-10',
|
|
91
|
+
!isActive && compact && 'max-sm:hidden',
|
|
92
|
+
)}
|
|
93
|
+
>
|
|
94
|
+
{card.number}
|
|
95
|
+
</span>
|
|
96
|
+
|
|
97
|
+
<span
|
|
98
|
+
className={cn(
|
|
99
|
+
'absolute bottom-3 left-3 whitespace-nowrap text-[10px] font-semibold uppercase tracking-[0.2em] [writing-mode:vertical-rl] sm:bottom-4 sm:left-4',
|
|
100
|
+
isActive ? 'opacity-0' : 'opacity-100',
|
|
101
|
+
compact && 'max-sm:left-1/2 max-sm:-translate-x-1/2 max-sm:text-[8px] max-sm:tracking-[0.12em]',
|
|
102
|
+
)}
|
|
103
|
+
>
|
|
104
|
+
{card.title}
|
|
105
|
+
</span>
|
|
106
|
+
|
|
107
|
+
<span
|
|
108
|
+
className={cn(
|
|
109
|
+
'absolute inset-x-0 bottom-0 block p-4 transition-all sm:p-5',
|
|
110
|
+
isActive ? 'translate-y-0 opacity-100' : 'translate-y-4 opacity-0',
|
|
111
|
+
)}
|
|
112
|
+
style={{
|
|
113
|
+
transitionDelay: `${delay}ms`,
|
|
114
|
+
transitionDuration: `${duration}ms`,
|
|
115
|
+
transitionTimingFunction: easing,
|
|
116
|
+
}}
|
|
117
|
+
>
|
|
118
|
+
<span className="block text-[9px] font-bold uppercase tracking-[0.22em] opacity-55">
|
|
119
|
+
Étape {card.number} · {card.kicker}
|
|
120
|
+
</span>
|
|
121
|
+
<span
|
|
122
|
+
className={cn(
|
|
123
|
+
'display mt-2 block text-3xl leading-[0.95] sm:text-5xl',
|
|
124
|
+
compact && 'max-sm:text-2xl',
|
|
125
|
+
)}
|
|
126
|
+
>
|
|
127
|
+
{card.title}
|
|
128
|
+
</span>
|
|
129
|
+
<span className="mt-3 block max-w-xs text-[11px] leading-relaxed opacity-65">
|
|
130
|
+
{card.description}
|
|
131
|
+
</span>
|
|
132
|
+
<span className="mt-4 inline-flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-[0.16em]">
|
|
133
|
+
Continuer
|
|
134
|
+
<ArrowRight className="size-3.5" />
|
|
135
|
+
</span>
|
|
136
|
+
</span>
|
|
137
|
+
</button>
|
|
138
|
+
)
|
|
139
|
+
})}
|
|
140
|
+
</div>
|
|
141
|
+
)
|
|
142
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react'
|
|
2
|
+
import { ArrowDown } from 'lucide-react'
|
|
3
|
+
import { cn } from '@/lib/utils'
|
|
4
|
+
import { makeMotionCards } from './data'
|
|
5
|
+
import { cardBackground, paletteAura, type CardPalette } from './palette'
|
|
6
|
+
|
|
7
|
+
export interface HorizontalStoryProps {
|
|
8
|
+
count?: number
|
|
9
|
+
palette?: CardPalette
|
|
10
|
+
delay?: number
|
|
11
|
+
duration?: number
|
|
12
|
+
easing?: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function HorizontalStory({
|
|
16
|
+
count = 6,
|
|
17
|
+
palette,
|
|
18
|
+
delay = 0,
|
|
19
|
+
duration = 500,
|
|
20
|
+
easing = 'cubic-bezier(0.65, 0, 0.35, 1)',
|
|
21
|
+
}: HorizontalStoryProps) {
|
|
22
|
+
const cards = makeMotionCards(count).map((card) => ({
|
|
23
|
+
...card,
|
|
24
|
+
gradient: cardBackground(palette, card.gradient),
|
|
25
|
+
}))
|
|
26
|
+
const section = useRef<HTMLDivElement>(null)
|
|
27
|
+
const viewport = useRef<HTMLDivElement>(null)
|
|
28
|
+
const track = useRef<HTMLDivElement>(null)
|
|
29
|
+
const flat = palette?.mode === 'solid'
|
|
30
|
+
const [progress, setProgress] = useState(0)
|
|
31
|
+
const [distance, setDistance] = useState(0)
|
|
32
|
+
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
const measure = () => {
|
|
35
|
+
const trackElement = track.current
|
|
36
|
+
const viewportElement = viewport.current
|
|
37
|
+
if (!trackElement || !viewportElement) return
|
|
38
|
+
setDistance(Math.max(0, trackElement.scrollWidth - viewportElement.clientWidth))
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
measure()
|
|
42
|
+
const observer = new ResizeObserver(measure)
|
|
43
|
+
if (track.current) observer.observe(track.current)
|
|
44
|
+
if (viewport.current) observer.observe(viewport.current)
|
|
45
|
+
window.addEventListener('resize', measure)
|
|
46
|
+
return () => {
|
|
47
|
+
observer.disconnect()
|
|
48
|
+
window.removeEventListener('resize', measure)
|
|
49
|
+
}
|
|
50
|
+
}, [count])
|
|
51
|
+
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
let frame = 0
|
|
54
|
+
const update = () => {
|
|
55
|
+
cancelAnimationFrame(frame)
|
|
56
|
+
frame = requestAnimationFrame(() => {
|
|
57
|
+
const element = section.current
|
|
58
|
+
if (!element) return
|
|
59
|
+
const total = element.offsetHeight - window.innerHeight + 57
|
|
60
|
+
const top = element.getBoundingClientRect().top - 57
|
|
61
|
+
setProgress(total > 0 ? Math.min(1, Math.max(0, -top / total)) : 0)
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
update()
|
|
66
|
+
window.addEventListener('scroll', update, { passive: true })
|
|
67
|
+
window.addEventListener('resize', update)
|
|
68
|
+
return () => {
|
|
69
|
+
window.removeEventListener('scroll', update)
|
|
70
|
+
window.removeEventListener('resize', update)
|
|
71
|
+
cancelAnimationFrame(frame)
|
|
72
|
+
}
|
|
73
|
+
}, [])
|
|
74
|
+
|
|
75
|
+
const active = Math.min(cards.length - 1, Math.round(progress * (cards.length - 1)))
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<>
|
|
79
|
+
<div
|
|
80
|
+
ref={section}
|
|
81
|
+
className="relative hidden md:block"
|
|
82
|
+
style={{ height: `calc(100dvh + ${(cards.length - 1) * 60}vh)` }}
|
|
83
|
+
>
|
|
84
|
+
<div className="sticky top-[57px] h-[calc(100dvh-57px)] overflow-hidden rounded-sm border bg-card/60">
|
|
85
|
+
<div
|
|
86
|
+
aria-hidden
|
|
87
|
+
className="pointer-events-none absolute inset-0"
|
|
88
|
+
style={{
|
|
89
|
+
background: paletteAura(palette),
|
|
90
|
+
}}
|
|
91
|
+
/>
|
|
92
|
+
|
|
93
|
+
<div className="absolute inset-x-0 top-0 z-20 flex items-center justify-between px-5 py-4">
|
|
94
|
+
<span className="inline-flex items-center gap-2 font-mono text-[9px] uppercase tracking-[0.22em] text-muted-foreground">
|
|
95
|
+
<ArrowDown className="size-3" />
|
|
96
|
+
Scroll pour avancer
|
|
97
|
+
</span>
|
|
98
|
+
<span className="font-mono text-[9px] uppercase tracking-[0.22em] text-muted-foreground">
|
|
99
|
+
{String(active + 1).padStart(2, '0')} / {String(cards.length).padStart(2, '0')}
|
|
100
|
+
</span>
|
|
101
|
+
</div>
|
|
102
|
+
|
|
103
|
+
<div ref={viewport} className="flex h-full items-center overflow-hidden">
|
|
104
|
+
<div
|
|
105
|
+
ref={track}
|
|
106
|
+
className="flex w-max items-center gap-5 pl-[6vw] pr-[12vw] will-change-transform"
|
|
107
|
+
style={{ transform: `translate3d(-${progress * distance}px, 0, 0)` }}
|
|
108
|
+
>
|
|
109
|
+
{cards.map((card, index) => {
|
|
110
|
+
const isActive = index === active
|
|
111
|
+
return (
|
|
112
|
+
<article
|
|
113
|
+
key={card.id}
|
|
114
|
+
aria-current={isActive ? 'step' : undefined}
|
|
115
|
+
className={cn(
|
|
116
|
+
'relative h-[min(62vh,500px)] w-[min(68vw,580px)] shrink-0 overflow-hidden rounded-sm border text-left transition-[opacity,border-color,transform]',
|
|
117
|
+
isActive
|
|
118
|
+
? 'border-primary/50 opacity-100'
|
|
119
|
+
: 'border-border opacity-45',
|
|
120
|
+
)}
|
|
121
|
+
data-motion-card
|
|
122
|
+
style={{
|
|
123
|
+
background: card.gradient,
|
|
124
|
+
transitionDelay: `${delay}ms`,
|
|
125
|
+
transitionDuration: `${duration}ms`,
|
|
126
|
+
transitionTimingFunction: easing,
|
|
127
|
+
}}
|
|
128
|
+
>
|
|
129
|
+
{!flat && (
|
|
130
|
+
<>
|
|
131
|
+
<span data-card-scrim className="absolute inset-0 bg-black/35" />
|
|
132
|
+
<span data-card-scrim className="absolute inset-x-0 bottom-0 h-2/3 bg-gradient-to-t from-black/85 via-black/30 to-transparent" />
|
|
133
|
+
</>
|
|
134
|
+
)}
|
|
135
|
+
|
|
136
|
+
<span className="absolute right-5 top-3 font-mono text-7xl font-black leading-none tracking-tighter text-white/15">
|
|
137
|
+
{card.number}
|
|
138
|
+
</span>
|
|
139
|
+
<span className="absolute left-5 top-5 text-[9px] font-semibold uppercase tracking-[0.22em] text-white/60">
|
|
140
|
+
Chapitre {card.number} · {card.kicker}
|
|
141
|
+
</span>
|
|
142
|
+
|
|
143
|
+
<span className="absolute inset-x-0 bottom-0 block p-6">
|
|
144
|
+
<span className="display block text-4xl leading-none text-white lg:text-5xl">
|
|
145
|
+
{card.title}
|
|
146
|
+
</span>
|
|
147
|
+
<span className="mt-3 block max-w-md text-[12px] leading-relaxed text-white/70">
|
|
148
|
+
{card.description}
|
|
149
|
+
</span>
|
|
150
|
+
</span>
|
|
151
|
+
</article>
|
|
152
|
+
)
|
|
153
|
+
})}
|
|
154
|
+
</div>
|
|
155
|
+
</div>
|
|
156
|
+
|
|
157
|
+
<div className="absolute inset-x-0 bottom-0 z-20 px-5 py-4">
|
|
158
|
+
<div className="h-px overflow-hidden bg-border">
|
|
159
|
+
<div
|
|
160
|
+
className="h-full bg-primary transition-[width] duration-150"
|
|
161
|
+
style={{ width: `${Math.round(progress * 100)}%` }}
|
|
162
|
+
/>
|
|
163
|
+
</div>
|
|
164
|
+
</div>
|
|
165
|
+
</div>
|
|
166
|
+
</div>
|
|
167
|
+
|
|
168
|
+
<div className="md:hidden">
|
|
169
|
+
<div className="flex snap-x snap-mandatory gap-3 overflow-x-auto pb-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
|
170
|
+
{cards.map((card) => (
|
|
171
|
+
<article
|
|
172
|
+
key={card.id}
|
|
173
|
+
className="relative h-[340px] w-[82%] shrink-0 snap-center overflow-hidden rounded-sm border border-border"
|
|
174
|
+
data-motion-card
|
|
175
|
+
style={{ background: card.gradient }}
|
|
176
|
+
>
|
|
177
|
+
{!flat && (
|
|
178
|
+
<>
|
|
179
|
+
<span data-card-scrim className="absolute inset-0 bg-black/35" />
|
|
180
|
+
<span data-card-scrim className="absolute inset-x-0 bottom-0 h-2/3 bg-gradient-to-t from-black/85 via-black/30 to-transparent" />
|
|
181
|
+
</>
|
|
182
|
+
)}
|
|
183
|
+
<span className="absolute left-4 top-4 font-mono text-[10px] tracking-[0.2em] text-white/70">
|
|
184
|
+
{card.number}
|
|
185
|
+
</span>
|
|
186
|
+
<span className="absolute inset-x-0 bottom-0 block p-5">
|
|
187
|
+
<span className="block text-[9px] font-semibold uppercase tracking-[0.22em] text-white/60">
|
|
188
|
+
{card.kicker}
|
|
189
|
+
</span>
|
|
190
|
+
<span className="display mt-1.5 block text-3xl leading-none text-white">
|
|
191
|
+
{card.title}
|
|
192
|
+
</span>
|
|
193
|
+
<span className="mt-2 block text-[11px] leading-relaxed text-white/70">
|
|
194
|
+
{card.description}
|
|
195
|
+
</span>
|
|
196
|
+
</span>
|
|
197
|
+
</article>
|
|
198
|
+
))}
|
|
199
|
+
</div>
|
|
200
|
+
<p className="mt-1 text-[10px] text-muted-foreground">Glisse horizontalement.</p>
|
|
201
|
+
</div>
|
|
202
|
+
</>
|
|
203
|
+
)
|
|
204
|
+
}
|