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.
@@ -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
+ }