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,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
|
+
}
|