apex-ui-kit 2.1.3 → 2.2.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,289 @@
1
+ import React, { useEffect, useRef } from "react";
2
+
3
+ /*
4
+ * Dot Globe: a 3D globe of land dots drawn on a 2D canvas (no WebGL, no map
5
+ * library). Drag to spin it with inertia; it also turns on its own. Cities
6
+ * pulse as markers and flights arc between them.
7
+ */
8
+
9
+ // 2° land mask (Natural Earth 110m): 180 × 69 cells from 180°W, 78°N, one bit each.
10
+ const LAND = "AAAAwADkw////wEABAAAAABgAAAAAAAAAADAUT8A/v8PAAAAAAwA/j8AuAEAAAAAcBHtDcD/fwAAAAAwAPz/fwMAABCAAQD5w/wD8P8FAAAcAIL7//9//xOAAP/ff0668QD/HwAA+A8At///////v3/w/////x8++B8AAOD/1//7////////jP////+/+AE/gAcAn9f//////////w/w/////wEs4AEAAHz+////////////gL////8HeAAcAADg5///////////9ADgAf7/f4AnAAAAAH78////////H0QAAAiA//8f8AcAAIBB4////////38ADwAQAOD//5//AQAAHAT/////////A3AAAAAA/v//+T8AAGDz//////////8DAQAAAMD/////AwAAsP//////////LwAAAAAA6P///2IAAAD+//////////8CAAAAAAD///8/CAAA4P//////////JwAAAAAA8P///wYAAAD+/unz/////z8AAAAAAAD///8HAAAA/pgPPP//////MQAAAAAA8P//PwAAAMBD9v7n/////wcBAAAAAAD///8AAAAAPkD7f/7///8hEAAAAAAA4P//DwAAAIDhAv/n////f8YAAAAAAAD8//8AAAAA+AdE//////8jDwAAAAAAgP//AwAAAMD/APD/////PxgAAAAAAADw/x8AAAAA/n/v//////8HAAAAAAAAAPwDAgAAAOD////7////fwAAAAAAAACgHyAAAACA//9/f/7///8DAAAAAAAAAPQBAAAAAPj//+cv+P//PwAAAAAAAAAAHjAAAADA/////g/+//8EAAAAAAAAAOBhCAAAAP7//99/4D//AAAAAAAAAAAAPAMEAADA////+Qf84BcAAAAAAAAAAAA/AAAAAPz//58fgAf+QAAAAAAAAAAAAA8AAADg////ewA4gA8EAAAAAAAAAADAAAAAAPz//38BgAP4QQAAAAAAAAAAAAgPAADA////zwAwgAwQAAAAAAAAAAAA9Q8AAPj///8HAAVIAAAAAAAAAAAAAID/AQAA////fwBAAAAQAAAAAAAAAAAA+P8AAGDh//8DAAA0GAAAAAAAAAAAAID/HwAAAPj/HwAAgMIBAAAAAAAAAAAA/P8BAACA//8AAAAYXgAAAAAAAAAAAMD/fwAAAPz/BwAAAOOBAQAAAAAAAAAA/P8/AACA/z8AAABgbtQBAAAAAAAAAOD//w8AAPD/AwAAAAQIeAAAAAAAAAAA/P//AQAA/z8AAACAA4APAQAAAAAAAID//w8AAPD/AwAAAAARsEAAAAAAAAAA+P9/AAAA/j8AAAAAAAAAAAAAAAAAAAD//wcAAPD/QwAAAACAIwAAAAAAAAAA8P9/AAAA/z8EAAAAAD8GIAAAAAAAAAD8/wMAAPD/cQAAAAD4ZwAAAAAAAAAAgP8/AAAA/w8HAAAAgP8HAAAAAAAAAAD4/wMAAOD/MAAAAAD//wEBAAAAAAAAgP8PAAAA/g8DAAAA+P8fAAAAAAAAAAD4PwAAAOB/EAAAAID//wMAAAAAAAAAgP8DAAAA/AMAAAAA+P9/AAAAAAAAAAD8HwAAAMA/AAAAAID//wcAAAAAAAAAwP8BAAAA+AEAAAAA8P9/AAAAAAAAAAD8DwAAAIAPAAAAAAAP/gMAAAAAAAAAwB8AAAAAAAAAAAAAEIAfAAEAAAAAAAD+AwAAAAAAAAAAAAAA8AEgAAAAAAAA4AcAAAAAAAAAAAAAAAAAAAYAAAAAAABeAAAAAAAAAAAAAAAAwAAwAAAAAAAAwAMAAAAAAAAAAAAAAAAIgAEAAAAAAAAeAAAAAAAAAAAAAAAAAAAMAAAAAAAA4AEAAAAAAAAAAAAAAAAAAAAAAAAAAAAPAAAAAAAAAAABAAAAAAAAAAAAAAAAcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOAAAAAAAAAAAAAAAAAAAAAAAAAAAAwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
11
+ const STEP = 2;
12
+ const COLS = 180;
13
+ const ROWS = 69;
14
+
15
+ const landDots = (() => {
16
+ const bin = typeof atob === "function" ? atob(LAND) : "";
17
+ const dots = [];
18
+ for (let r = 0; r < ROWS; r++) {
19
+ const lat = 78 - r * STEP - 1;
20
+ // Fewer dots toward the poles so the globe looks evenly filled.
21
+ const skip = Math.max(1, Math.round(1 / Math.max(0.25, Math.cos((lat * Math.PI) / 180))));
22
+ for (let c = 0; c < COLS; c += skip) {
23
+ const i = r * COLS + c;
24
+ if ((bin.charCodeAt(i >> 3) >> (i & 7)) & 1) dots.push([lat, -180 + c * STEP + 1]);
25
+ }
26
+ }
27
+ return dots;
28
+ })();
29
+
30
+ const DEFAULT_MARKERS = [
31
+ { label: "Bhubaneswar", lat: 20.3, lon: 85.8 },
32
+ { label: "San Francisco", lat: 37.8, lon: -122.4 },
33
+ { label: "London", lat: 51.5, lon: -0.1 },
34
+ { label: "Tokyo", lat: 35.7, lon: 139.7 },
35
+ { label: "Sydney", lat: -33.9, lon: 151.2 },
36
+ { label: "São Paulo", lat: -23.5, lon: -46.6 },
37
+ { label: "Dubai", lat: 25.2, lon: 55.3 },
38
+ ];
39
+
40
+ const DEFAULT_ARCS = [
41
+ [0, 1],
42
+ [0, 2],
43
+ [0, 3],
44
+ [0, 4],
45
+ [2, 5],
46
+ [6, 2],
47
+ [3, 1],
48
+ ];
49
+
50
+ const rad = (d) => (d * Math.PI) / 180;
51
+ const toVec = (lat, lon) => [Math.cos(rad(lat)) * Math.sin(rad(lon)), Math.sin(rad(lat)), Math.cos(rad(lat)) * Math.cos(rad(lon))];
52
+
53
+ // Spherical interpolation between two unit vectors.
54
+ const slerp = (a, b, t) => {
55
+ const dot = Math.min(1, Math.max(-1, a[0] * b[0] + a[1] * b[1] + a[2] * b[2]));
56
+ const om = Math.acos(dot);
57
+ if (om < 1e-4) return a;
58
+ const s = Math.sin(om);
59
+ const k1 = Math.sin((1 - t) * om) / s;
60
+ const k2 = Math.sin(t * om) / s;
61
+ return [a[0] * k1 + b[0] * k2, a[1] * k1 + b[1] * k2, a[2] * k1 + b[2] * k2];
62
+ };
63
+
64
+ const DotGlobe = ({
65
+ markers = DEFAULT_MARKERS,
66
+ arcs = DEFAULT_ARCS,
67
+ dotColor = "#a1a1aa",
68
+ markerColor = "#a3e635",
69
+ arcColor = "#a3e635",
70
+ glowColor = "#a3e635",
71
+ labelColor = "rgba(255,255,255,0.9)",
72
+ autoRotateSpeed = 8,
73
+ tilt = 18,
74
+ maxSize = 30,
75
+ showLabels = true,
76
+ className = "",
77
+ }) => {
78
+ const wrapRef = useRef(null);
79
+ const canvasRef = useRef(null);
80
+ const opts = useRef(null);
81
+ opts.current = { markers, arcs, dotColor, markerColor, arcColor, glowColor, labelColor, autoRotateSpeed, tilt, showLabels };
82
+
83
+ useEffect(() => {
84
+ const canvas = canvasRef.current;
85
+ const wrap = wrapRef.current;
86
+ const ctx = canvas.getContext("2d");
87
+ const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
88
+ let size = 300;
89
+ let raf = 0;
90
+ let last = performance.now();
91
+ let time = 0;
92
+ let yaw = -80; // start over India
93
+ let velocity = 0;
94
+ let drag = null;
95
+ let visible = true;
96
+
97
+ const resize = () => {
98
+ const dpr = Math.min(window.devicePixelRatio || 1, 2);
99
+ size = wrap.getBoundingClientRect().width;
100
+ canvas.width = Math.round(size * dpr);
101
+ canvas.height = Math.round(size * dpr);
102
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
103
+ draw();
104
+ };
105
+
106
+ // Rotate a unit vector by the current yaw and tilt, then project.
107
+ const project = (v, R, c) => {
108
+ const cy = Math.cos(rad(yaw));
109
+ const sy = Math.sin(rad(yaw));
110
+ const x = v[0] * cy + v[2] * sy;
111
+ const z0 = -v[0] * sy + v[2] * cy;
112
+ const ct = Math.cos(rad(opts.current.tilt));
113
+ const st = Math.sin(rad(opts.current.tilt));
114
+ const y = v[1] * ct - z0 * st;
115
+ const z = v[1] * st + z0 * ct;
116
+ return [c + x * R, c - y * R, z];
117
+ };
118
+
119
+ function draw() {
120
+ const o = opts.current;
121
+ const c = size / 2;
122
+ const R = size * 0.4;
123
+ ctx.clearRect(0, 0, size, size);
124
+
125
+ // Atmosphere glow and sphere body.
126
+ const glow = ctx.createRadialGradient(c, c, R * 0.85, c, c, R * 1.25);
127
+ glow.addColorStop(0, `${o.glowColor}33`);
128
+ glow.addColorStop(1, `${o.glowColor}00`);
129
+ ctx.fillStyle = glow;
130
+ ctx.beginPath();
131
+ ctx.arc(c, c, R * 1.25, 0, Math.PI * 2);
132
+ ctx.fill();
133
+ const body = ctx.createRadialGradient(c - R * 0.35, c - R * 0.35, R * 0.1, c, c, R);
134
+ body.addColorStop(0, "rgba(255,255,255,0.07)");
135
+ body.addColorStop(1, "rgba(255,255,255,0.01)");
136
+ ctx.fillStyle = body;
137
+ ctx.beginPath();
138
+ ctx.arc(c, c, R, 0, Math.PI * 2);
139
+ ctx.fill();
140
+
141
+ // Land dots: front bright, back faint.
142
+ const dr = Math.max(0.8, R / 190);
143
+ ctx.fillStyle = o.dotColor;
144
+ for (let i = 0; i < landDots.length; i++) {
145
+ const [lat, lon] = landDots[i];
146
+ const [x, y, z] = project(toVec(lat, lon), R, c);
147
+ ctx.globalAlpha = z > 0 ? 0.35 + 0.65 * z : 0.06;
148
+ ctx.fillRect(x - dr, y - dr, dr * 2, dr * 2);
149
+ }
150
+ ctx.globalAlpha = 1;
151
+
152
+ // Arcs with a travelling head.
153
+ o.arcs.forEach(([a, b], k) => {
154
+ const A = o.markers[a];
155
+ const B = o.markers[b];
156
+ if (!A || !B) return;
157
+ const va = toVec(A.lat, A.lon);
158
+ const vb = toVec(B.lat, B.lon);
159
+ const steps = 48;
160
+ ctx.strokeStyle = o.arcColor;
161
+ ctx.lineWidth = Math.max(1, R / 220);
162
+ ctx.globalAlpha = 0.55;
163
+ ctx.beginPath();
164
+ let drawing = false;
165
+ for (let s = 0; s <= steps; s++) {
166
+ const t = s / steps;
167
+ const v = slerp(va, vb, t);
168
+ const lift = 1 + 0.18 * Math.sin(Math.PI * t);
169
+ const [x, y, z] = project([v[0] * lift, v[1] * lift, v[2] * lift], R, c);
170
+ if (z > -0.1) {
171
+ if (drawing) ctx.lineTo(x, y);
172
+ else ctx.moveTo(x, y);
173
+ drawing = true;
174
+ } else drawing = false;
175
+ }
176
+ ctx.stroke();
177
+ const t = ((time * 0.35 + k * 0.17) % 1 + 1) % 1;
178
+ const v = slerp(va, vb, t);
179
+ const lift = 1 + 0.18 * Math.sin(Math.PI * t);
180
+ const [hx, hy, hz] = project([v[0] * lift, v[1] * lift, v[2] * lift], R, c);
181
+ if (hz > 0) {
182
+ ctx.globalAlpha = 1;
183
+ ctx.fillStyle = o.arcColor;
184
+ ctx.beginPath();
185
+ ctx.arc(hx, hy, Math.max(1.5, R / 110), 0, Math.PI * 2);
186
+ ctx.fill();
187
+ }
188
+ });
189
+ ctx.globalAlpha = 1;
190
+
191
+ // Markers with a pulse and labels.
192
+ ctx.font = `600 ${Math.max(10, R / 18)}px ui-sans-serif, system-ui, sans-serif`;
193
+ o.markers.forEach((m, k) => {
194
+ const [x, y, z] = project(toVec(m.lat, m.lon), R, c);
195
+ if (z <= 0.05) return;
196
+ const pulse = ((time * 0.8 + k * 0.23) % 1 + 1) % 1;
197
+ ctx.strokeStyle = o.markerColor;
198
+ ctx.globalAlpha = (1 - pulse) * z;
199
+ ctx.lineWidth = 1.5;
200
+ ctx.beginPath();
201
+ ctx.arc(x, y, 3 + pulse * R * 0.06, 0, Math.PI * 2);
202
+ ctx.stroke();
203
+ ctx.globalAlpha = z;
204
+ ctx.fillStyle = o.markerColor;
205
+ ctx.beginPath();
206
+ ctx.arc(x, y, Math.max(2.5, R / 80), 0, Math.PI * 2);
207
+ ctx.fill();
208
+ if (o.showLabels && z > 0.35) {
209
+ ctx.globalAlpha = Math.min(1, (z - 0.35) * 3);
210
+ ctx.fillStyle = o.labelColor;
211
+ ctx.fillText(m.label, x + 8, y + 4);
212
+ }
213
+ });
214
+ ctx.globalAlpha = 1;
215
+ }
216
+
217
+ const loop = (now) => {
218
+ const dt = Math.min(0.05, (now - last) / 1000);
219
+ last = now;
220
+ time += dt;
221
+ if (!drag) {
222
+ velocity *= 0.94;
223
+ yaw += velocity * dt * 60 + (reduced ? 0 : opts.current.autoRotateSpeed * dt) * (Math.abs(velocity) < 0.05 ? 1 : 0.3);
224
+ }
225
+ draw();
226
+ raf = requestAnimationFrame(loop);
227
+ };
228
+ const start = () => {
229
+ if (raf || !visible) return;
230
+ last = performance.now();
231
+ raf = requestAnimationFrame(loop);
232
+ };
233
+ const stop = () => {
234
+ cancelAnimationFrame(raf);
235
+ raf = 0;
236
+ };
237
+
238
+ const onDown = (e) => {
239
+ drag = { x: e.clientX, yaw, lastX: e.clientX, lastT: performance.now() };
240
+ canvas.setPointerCapture?.(e.pointerId);
241
+ };
242
+ const onMove = (e) => {
243
+ if (!drag) return;
244
+ const degPerPx = 180 / (size * 0.8);
245
+ // Drag right → the surface under the pointer moves right.
246
+ yaw = drag.yaw + (e.clientX - drag.x) * degPerPx;
247
+ const now = performance.now();
248
+ velocity = ((e.clientX - drag.lastX) * degPerPx) / Math.max(1, (now - drag.lastT) / 16.7);
249
+ drag.lastX = e.clientX;
250
+ drag.lastT = now;
251
+ };
252
+ const onUp = () => {
253
+ drag = null;
254
+ };
255
+
256
+ canvas.addEventListener("pointerdown", onDown);
257
+ canvas.addEventListener("pointermove", onMove);
258
+ canvas.addEventListener("pointerup", onUp);
259
+ canvas.addEventListener("pointercancel", onUp);
260
+ const ro = new ResizeObserver(resize);
261
+ ro.observe(wrap);
262
+ const io = new IntersectionObserver(([entry]) => {
263
+ visible = entry.isIntersecting;
264
+ if (visible) start();
265
+ else stop();
266
+ });
267
+ io.observe(wrap);
268
+ resize();
269
+ start();
270
+
271
+ return () => {
272
+ stop();
273
+ ro.disconnect();
274
+ io.disconnect();
275
+ canvas.removeEventListener("pointerdown", onDown);
276
+ canvas.removeEventListener("pointermove", onMove);
277
+ canvas.removeEventListener("pointerup", onUp);
278
+ canvas.removeEventListener("pointercancel", onUp);
279
+ };
280
+ }, []);
281
+
282
+ return (
283
+ <div ref={wrapRef} className={`relative aspect-square w-full ${className}`} style={{ maxWidth: `${maxSize}rem` }}>
284
+ <canvas ref={canvasRef} role="img" aria-label="Interactive globe" className="h-full w-full cursor-grab touch-pan-y active:cursor-grabbing" />
285
+ </div>
286
+ );
287
+ };
288
+
289
+ export default DotGlobe;
@@ -0,0 +1,88 @@
1
+ import React, { useId, useState } from "react";
2
+ import { AnimatePresence, motion } from "framer-motion";
3
+ import { Plus } from "lucide-react";
4
+
5
+ /*
6
+ * FAQ Accordion: questions that open with a smooth height animation, a plus
7
+ * that turns into a cross, and a soft highlight on the open item. One or many
8
+ * items can be open at once.
9
+ */
10
+
11
+ const DEFAULT_ITEMS = [
12
+ { q: "Is ApexUI free to use?", a: "Yes. Every component is free and open source — copy it into your project or add it with one CLI command." },
13
+ { q: "Does it work with Next.js?", a: "Yes. The components are plain React with Tailwind CSS, so they work in Vite, Next.js, Remix and anything else that runs React." },
14
+ { q: "Can I change the colors?", a: "Every component takes props for its accent colors, and the Tailwind classes are right there in the code if you want to go further." },
15
+ { q: "Do the animations respect reduced motion?", a: "Yes. Components check the visitor's reduced-motion setting and tone their movement down or switch it off." },
16
+ ];
17
+
18
+ const FaqAccordion = ({ items = DEFAULT_ITEMS, multiple = false, defaultOpen = [0], accentColor = "#a3e635", theme = "dark", className = "" }) => {
19
+ const id = useId();
20
+ const [open, setOpen] = useState(() => new Set(defaultOpen));
21
+ const dark = theme !== "light";
22
+
23
+ const toggle = (i) =>
24
+ setOpen((prev) => {
25
+ const next = new Set(multiple ? prev : prev.has(i) ? [i] : []);
26
+ if (next.has(i)) next.delete(i);
27
+ else next.add(i);
28
+ return next;
29
+ });
30
+
31
+ return (
32
+ <div className={`w-full max-w-2xl space-y-2 ${className}`}>
33
+ {items.map((item, i) => {
34
+ const on = open.has(i);
35
+ return (
36
+ <div
37
+ key={i}
38
+ className={`overflow-hidden rounded-2xl border transition-colors duration-300 ${dark ? "text-white" : "text-neutral-900"} ${
39
+ on ? (dark ? "border-white/15 bg-white/[0.05]" : "border-black/10 bg-black/[0.03]") : dark ? "border-white/10 bg-transparent hover:bg-white/[0.03]" : "border-black/10 bg-transparent hover:bg-black/[0.02]"
40
+ }`}
41
+ >
42
+ <button
43
+ type="button"
44
+ id={`${id}-q-${i}`}
45
+ aria-expanded={on}
46
+ aria-controls={`${id}-a-${i}`}
47
+ onClick={() => toggle(i)}
48
+ className="flex w-full items-center justify-between gap-4 px-5 py-4 text-left text-[0.95rem] font-semibold outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-current/30"
49
+ >
50
+ {item.q}
51
+ <motion.span
52
+ animate={{ rotate: on ? 45 : 0, backgroundColor: on ? accentColor : dark ? "rgba(255,255,255,0.06)" : "rgba(0,0,0,0.05)", color: on ? "#0a0a0a" : dark ? "#fff" : "#111" }}
53
+ transition={{ type: "spring", stiffness: 380, damping: 26 }}
54
+ className="grid h-7 w-7 shrink-0 place-items-center rounded-full"
55
+ >
56
+ <Plus className="h-4 w-4" />
57
+ </motion.span>
58
+ </button>
59
+ <AnimatePresence initial={false}>
60
+ {on && (
61
+ <motion.div
62
+ id={`${id}-a-${i}`}
63
+ role="region"
64
+ aria-labelledby={`${id}-q-${i}`}
65
+ initial={{ height: 0, opacity: 0 }}
66
+ animate={{ height: "auto", opacity: 1 }}
67
+ exit={{ height: 0, opacity: 0 }}
68
+ transition={{ height: { duration: 0.32, ease: [0.22, 1, 0.36, 1] }, opacity: { duration: 0.2 } }}
69
+ >
70
+ <motion.p
71
+ initial={{ y: -6 }}
72
+ animate={{ y: 0 }}
73
+ exit={{ y: -6 }}
74
+ className={`px-5 pb-5 text-[0.9rem] leading-relaxed ${dark ? "text-white/65" : "text-neutral-600"}`}
75
+ >
76
+ {item.a}
77
+ </motion.p>
78
+ </motion.div>
79
+ )}
80
+ </AnimatePresence>
81
+ </div>
82
+ );
83
+ })}
84
+ </div>
85
+ );
86
+ };
87
+
88
+ export default FaqAccordion;
@@ -0,0 +1,121 @@
1
+ import React, { useEffect, useRef, useState } from "react";
2
+ import { motion, useReducedMotion } from "framer-motion";
3
+
4
+ /*
5
+ * Flip Clock: split-flap digits that fold over when they change. Count down
6
+ * to a date, count down a number of seconds, or show the time of day.
7
+ */
8
+
9
+ const pad = (n) => String(n).padStart(2, "0");
10
+
11
+ function partsFor(mode, target, seconds, start) {
12
+ const now = Date.now();
13
+ if (mode === "clock") {
14
+ const d = new Date(now);
15
+ return [
16
+ ["Hours", pad(d.getHours())],
17
+ ["Minutes", pad(d.getMinutes())],
18
+ ["Seconds", pad(d.getSeconds())],
19
+ ];
20
+ }
21
+ const end = mode === "date" ? new Date(target).getTime() : start + seconds * 1000;
22
+ const left = Math.max(0, Math.floor((end - now) / 1000));
23
+ return [
24
+ ["Days", pad(Math.floor(left / 86400))],
25
+ ["Hours", pad(Math.floor((left % 86400) / 3600))],
26
+ ["Minutes", pad(Math.floor((left % 3600) / 60))],
27
+ ["Seconds", pad(left % 60)],
28
+ ];
29
+ }
30
+
31
+ // One half of a digit card: the full glyph, clipped to the top or bottom half.
32
+ function Half({ digit, top, card, style, ...motionProps }) {
33
+ return (
34
+ <motion.span
35
+ className={`absolute inset-x-0 h-1/2 overflow-hidden ${top ? "top-0 origin-bottom rounded-t-[0.14em]" : "bottom-0 origin-top rounded-b-[0.14em]"}`}
36
+ style={{ background: card, backfaceVisibility: "hidden", ...style }}
37
+ {...motionProps}
38
+ >
39
+ <span className={`absolute inset-x-0 grid h-[200%] place-items-center leading-none ${top ? "top-0" : "bottom-0"}`}>{digit}</span>
40
+ </motion.span>
41
+ );
42
+ }
43
+
44
+ function Flap({ digit, card, ink, moving }) {
45
+ const [prev, setPrev] = useState(digit);
46
+ const [flip, setFlip] = useState(0);
47
+
48
+ // A new digit: fold the old top half down, then swing the new bottom half in.
49
+ useEffect(() => {
50
+ if (digit === prev) return undefined;
51
+ if (!moving) {
52
+ setPrev(digit);
53
+ return undefined;
54
+ }
55
+ setFlip((f) => f + 1);
56
+ const t = setTimeout(() => setPrev(digit), 620);
57
+ return () => clearTimeout(t);
58
+ }, [digit, prev, moving]);
59
+
60
+ const flipping = digit !== prev;
61
+ return (
62
+ <span className="relative inline-block h-[1.35em] w-[0.9em] rounded-[0.14em] [perspective:400px]" style={{ color: ink }}>
63
+ <Half digit={digit} top card={card} />
64
+ <Half digit={flipping ? prev : digit} card={card} />
65
+ {flipping && (
66
+ <React.Fragment key={flip}>
67
+ <Half digit={prev} top card={card} style={{ zIndex: 2 }} initial={{ rotateX: 0 }} animate={{ rotateX: -90 }} transition={{ duration: 0.3, ease: "easeIn" }} />
68
+ <Half digit={digit} card={card} style={{ zIndex: 2 }} initial={{ rotateX: 90 }} animate={{ rotateX: 0 }} transition={{ duration: 0.3, delay: 0.3, ease: "easeOut" }} />
69
+ </React.Fragment>
70
+ )}
71
+ <span aria-hidden="true" className="absolute inset-x-0 top-1/2 z-[3] h-px bg-black/40" />
72
+ </span>
73
+ );
74
+ }
75
+
76
+ const FlipClock = ({ mode = "seconds", target, seconds = 3 * 86400 + 4 * 3600 + 25 * 60, accentColor = "#a3e635", theme = "dark", showLabels = true, size = 3, className = "" }) => {
77
+ const reduced = useReducedMotion();
78
+ const start = useRef(Date.now());
79
+ const [parts, setParts] = useState(() => partsFor(mode, target, seconds, start.current));
80
+
81
+ useEffect(() => {
82
+ start.current = Date.now();
83
+ const tick = () => setParts(partsFor(mode, target, seconds, start.current));
84
+ tick();
85
+ const id = setInterval(tick, 250);
86
+ return () => clearInterval(id);
87
+ }, [mode, target, seconds]);
88
+
89
+ const dark = theme !== "light";
90
+ const card = dark ? "#18181b" : "#f4f4f5";
91
+ const ink = dark ? "#fafafa" : "#18181b";
92
+
93
+ return (
94
+ <div role="timer" aria-live="off" aria-label={parts.map(([l, v]) => `${v} ${l}`).join(", ")} className={`inline-flex items-start gap-[0.35em] font-mono font-bold ${className}`} style={{ fontSize: `${size}rem` }}>
95
+ {parts.map(([label, value], i) => (
96
+ <React.Fragment key={label}>
97
+ {i > 0 && (
98
+ <span aria-hidden="true" className="flex h-[1.35em] flex-col justify-center gap-[0.18em]">
99
+ <span className="h-[0.12em] w-[0.12em] rounded-full" style={{ background: accentColor }} />
100
+ <span className="h-[0.12em] w-[0.12em] rounded-full" style={{ background: accentColor }} />
101
+ </span>
102
+ )}
103
+ <span className="flex flex-col items-center">
104
+ <span aria-hidden="true" className="flex gap-[0.08em]">
105
+ {[...value].map((d, k) => (
106
+ <Flap key={k} digit={d} card={card} ink={ink} moving={!reduced} />
107
+ ))}
108
+ </span>
109
+ {showLabels && (
110
+ <span className="mt-[0.4em] text-[0.22em] font-semibold uppercase tracking-[0.2em]" style={{ color: dark ? "rgba(255,255,255,0.5)" : "rgba(0,0,0,0.5)" }}>
111
+ {label}
112
+ </span>
113
+ )}
114
+ </span>
115
+ </React.Fragment>
116
+ ))}
117
+ </div>
118
+ );
119
+ };
120
+
121
+ export default FlipClock;