uifolio 0.6.0 → 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 +14 -4
- package/cli/uifolio.mjs +132 -25
- 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,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
|
+
}
|
|
@@ -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
|
+
}
|