apex-ui-kit 2.1.1 → 2.1.3

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,189 @@
1
+ import React, { useEffect, useRef } from "react";
2
+
3
+ /*
4
+ * Dot Wave Background: a grid of dots on a canvas with a slow wave of color
5
+ * rolling through it. Dots near the pointer swell, light up and lean away.
6
+ * Pauses while off screen and stays still for reduced-motion users.
7
+ */
8
+
9
+ const hexToRgb = (hex) => {
10
+ let h = String(hex).replace("#", "");
11
+ if (h.length === 3) h = h.split("").map((c) => c + c).join("");
12
+ const n = parseInt(h, 16) || 0;
13
+ return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
14
+ };
15
+
16
+ const POINTER_RADIUS = 150;
17
+
18
+ const DotWaveBackground = ({
19
+ children,
20
+ className = "",
21
+ containerClassName = "",
22
+ background = "#09090b",
23
+ dotColor = "#ffffff",
24
+ colors = ["#38bdf8", "#a78bfa"],
25
+ gap = 22,
26
+ dotSize = 1.2,
27
+ waveStrength = 1,
28
+ speed = 1,
29
+ interactive = true,
30
+ fade = true,
31
+ }) => {
32
+ const containerRef = useRef(null);
33
+ const canvasRef = useRef(null);
34
+ const pointer = useRef({ x: -9999, y: -9999, active: false, strength: 0 });
35
+ const redraw = useRef(() => {});
36
+ const settings = useRef(null);
37
+ settings.current = { dotColor, colors, gap, dotSize, waveStrength, speed, interactive };
38
+
39
+ useEffect(() => {
40
+ const canvas = canvasRef.current;
41
+ const container = containerRef.current;
42
+ const ctx = canvas.getContext("2d");
43
+ const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
44
+ let width = 0;
45
+ let height = 0;
46
+ let raf = 0;
47
+ let t = 0;
48
+ let last = performance.now();
49
+ let visible = true;
50
+
51
+ const draw = () => {
52
+ const s = settings.current;
53
+ const base = hexToRgb(s.dotColor);
54
+ const ca = hexToRgb(s.colors[0]);
55
+ const cb = hexToRgb(s.colors[1] ?? s.colors[0]);
56
+ const p = pointer.current;
57
+ p.strength += ((p.active && s.interactive && !reduced ? 1 : 0) - p.strength) * 0.08;
58
+
59
+ ctx.clearRect(0, 0, width, height);
60
+ const g = Math.max(8, s.gap);
61
+ const cols = Math.ceil(width / g) + 1;
62
+ const rows = Math.ceil(height / g) + 1;
63
+ const ox = (width - (cols - 1) * g) / 2;
64
+ const oy = (height - (rows - 1) * g) / 2;
65
+
66
+ for (let i = 0; i < cols; i++) {
67
+ const x0 = ox + i * g;
68
+ // Highlight color drifts from the first to the second color left to right.
69
+ const m = width ? Math.min(1, Math.max(0, x0 / width)) : 0;
70
+ const hr = ca[0] + (cb[0] - ca[0]) * m;
71
+ const hg = ca[1] + (cb[1] - ca[1]) * m;
72
+ const hb = ca[2] + (cb[2] - ca[2]) * m;
73
+
74
+ for (let j = 0; j < rows; j++) {
75
+ const y0 = oy + j * g;
76
+ // A diagonal band plus a ripple spreading from the centre.
77
+ const ripple = Math.hypot(x0 - width / 2, y0 - height / 2);
78
+ const wave = 0.55 * Math.sin(x0 * 0.009 + y0 * 0.005 - t * 1.3) + 0.45 * Math.sin(ripple * 0.018 - t * 1.6);
79
+ const lift = Math.min(1, Math.max(0, (wave + 0.3) / 0.95));
80
+ const v = Math.min(1, lift * lift * s.waveStrength);
81
+
82
+ let x = x0;
83
+ let y = y0;
84
+ let boost = 0;
85
+ if (p.strength > 0.01) {
86
+ const dx = x0 - p.x;
87
+ const dy = y0 - p.y;
88
+ const d = Math.hypot(dx, dy);
89
+ if (d < POINTER_RADIUS) {
90
+ boost = (1 - d / POINTER_RADIUS) ** 2 * p.strength;
91
+ const push = (boost * 9) / (d || 1);
92
+ x += dx * push;
93
+ y += dy * push;
94
+ }
95
+ }
96
+
97
+ const k = Math.min(1, v + boost);
98
+ const r = s.dotSize * (1 + v * 1.9 + boost * 1.8);
99
+ ctx.fillStyle = `rgba(${Math.round(base[0] + (hr - base[0]) * k)},${Math.round(base[1] + (hg - base[1]) * k)},${Math.round(base[2] + (hb - base[2]) * k)},${0.13 + k * 0.87})`;
100
+ ctx.beginPath();
101
+ ctx.arc(x, y, r, 0, Math.PI * 2);
102
+ ctx.fill();
103
+ }
104
+ }
105
+ };
106
+ redraw.current = draw;
107
+
108
+ const loop = (now) => {
109
+ const dt = Math.min(0.05, (now - last) / 1000);
110
+ last = now;
111
+ t += dt * settings.current.speed;
112
+ draw();
113
+ raf = requestAnimationFrame(loop);
114
+ };
115
+ const start = () => {
116
+ if (reduced || raf || !visible) return;
117
+ last = performance.now();
118
+ raf = requestAnimationFrame(loop);
119
+ };
120
+ const stop = () => {
121
+ cancelAnimationFrame(raf);
122
+ raf = 0;
123
+ };
124
+
125
+ const resize = () => {
126
+ const rect = container.getBoundingClientRect();
127
+ const dpr = Math.min(window.devicePixelRatio || 1, 2);
128
+ width = rect.width;
129
+ height = rect.height;
130
+ canvas.width = Math.round(width * dpr);
131
+ canvas.height = Math.round(height * dpr);
132
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
133
+ draw();
134
+ };
135
+
136
+ const ro = new ResizeObserver(resize);
137
+ ro.observe(container);
138
+ const io = new IntersectionObserver(([entry]) => {
139
+ visible = entry.isIntersecting;
140
+ if (visible) start();
141
+ else stop();
142
+ });
143
+ io.observe(container);
144
+ resize();
145
+ start();
146
+
147
+ return () => {
148
+ stop();
149
+ ro.disconnect();
150
+ io.disconnect();
151
+ };
152
+ }, []);
153
+
154
+ // Reduced motion draws one still frame, so redraw when the props change.
155
+ useEffect(() => {
156
+ redraw.current();
157
+ }, [dotColor, colors, gap, dotSize, waveStrength]);
158
+
159
+ const onPointerMove = (e) => {
160
+ const rect = containerRef.current.getBoundingClientRect();
161
+ pointer.current.x = e.clientX - rect.left;
162
+ pointer.current.y = e.clientY - rect.top;
163
+ pointer.current.active = true;
164
+ };
165
+ const onPointerLeave = () => {
166
+ pointer.current.active = false;
167
+ };
168
+
169
+ return (
170
+ <div
171
+ ref={containerRef}
172
+ onPointerMove={interactive ? onPointerMove : undefined}
173
+ onPointerLeave={onPointerLeave}
174
+ onPointerCancel={onPointerLeave}
175
+ className={`relative h-screen w-full overflow-hidden ${containerClassName}`}
176
+ style={{ background }}
177
+ >
178
+ <canvas
179
+ ref={canvasRef}
180
+ aria-hidden="true"
181
+ className="pointer-events-none absolute inset-0 h-full w-full"
182
+ style={fade ? { maskImage: "radial-gradient(ellipse 80% 75% at 50% 50%, #000 35%, transparent 100%)", WebkitMaskImage: "radial-gradient(ellipse 80% 75% at 50% 50%, #000 35%, transparent 100%)" } : undefined}
183
+ />
184
+ {children && <div className={`relative z-10 h-full w-full ${className}`}>{children}</div>}
185
+ </div>
186
+ );
187
+ };
188
+
189
+ export default DotWaveBackground;
@@ -0,0 +1,221 @@
1
+ import React, { useEffect, useRef, useState } from "react";
2
+ import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
3
+ import { MessageCircle, Pause, Phone, PhoneOff, Play, SkipBack, SkipForward, Timer } from "lucide-react";
4
+
5
+ /*
6
+ * Dynamic Island: a black capsule that morphs between live activities —
7
+ * idle, music, call, timer and notification. Click the music view to expand
8
+ * it. Runs through the views on its own, or follows a controlled `view`.
9
+ */
10
+
11
+ const IK = "https://ik.imagekit.io/ny6yinyut/apexui/assets";
12
+ const ALL_VIEWS = ["idle", "music", "call", "timer", "notification"];
13
+
14
+ // Capsule size per view, in rem so it scales with the page's font size.
15
+ const SIZES = {
16
+ idle: { width: "7.5rem", height: "2.1rem", borderRadius: "1.05rem" },
17
+ music: { width: "17rem", height: "2.6rem", borderRadius: "1.3rem" },
18
+ musicExpanded: { width: "22rem", height: "11.25rem", borderRadius: "2.4rem" },
19
+ call: { width: "22rem", height: "4.6rem", borderRadius: "2.3rem" },
20
+ timer: { width: "14rem", height: "2.6rem", borderRadius: "1.3rem" },
21
+ notification: { width: "22rem", height: "4.6rem", borderRadius: "2.3rem" },
22
+ };
23
+
24
+ const spring = { type: "spring", stiffness: 380, damping: 32, mass: 0.9 };
25
+
26
+ const formatTime = (s) => `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
27
+
28
+ function Waveform({ color, playing, moving }) {
29
+ const bars = [0.55, 1, 0.7, 0.9, 0.45];
30
+ return (
31
+ <span className="flex h-4 items-center gap-[3px]" aria-hidden="true">
32
+ {bars.map((peak, i) => (
33
+ <motion.span
34
+ key={i}
35
+ className="w-[3px] origin-center rounded-full"
36
+ style={{ backgroundColor: color, height: "100%" }}
37
+ animate={playing && moving ? { scaleY: [0.3, peak, 0.4, peak * 0.8, 0.3] } : { scaleY: 0.3 }}
38
+ transition={playing && moving ? { duration: 0.9 + i * 0.12, repeat: Infinity, ease: "easeInOut" } : { duration: 0.3 }}
39
+ />
40
+ ))}
41
+ </span>
42
+ );
43
+ }
44
+
45
+ const DynamicIsland = ({
46
+ view,
47
+ defaultView = "music",
48
+ views = ALL_VIEWS,
49
+ autoPlay = true,
50
+ interval = 3600,
51
+ accentColor = "#a3e635",
52
+ music = { title: "Midnight City", artist: "Apex Sessions", cover: `${IK}/spiderman.png?tr=f-auto,q-80,w-200` },
53
+ call = { name: "Tony Stark", subtitle: "Mobile", avatar: `${IK}/ironman.png?tr=f-auto,q-80,w-160` },
54
+ notification = { app: "Messages", title: "Peter Parker", message: "On my way — saving the city first 🕷️" },
55
+ timerSeconds = 300,
56
+ onViewChange,
57
+ className = "",
58
+ }) => {
59
+ const controlled = view !== undefined;
60
+ const [ownView, setOwnView] = useState(views.includes(defaultView) ? defaultView : views[0]);
61
+ const current = controlled ? view : ownView;
62
+ const [expanded, setExpanded] = useState(false);
63
+ const [playing, setPlaying] = useState(true);
64
+ const [hovered, setHovered] = useState(false);
65
+ const [remaining, setRemaining] = useState(timerSeconds);
66
+ const moving = !useReducedMotion();
67
+ const onViewChangeRef = useRef(onViewChange);
68
+ onViewChangeRef.current = onViewChange;
69
+
70
+ // Cycle through the views on its own (uncontrolled), pausing while hovered or expanded.
71
+ useEffect(() => {
72
+ if (controlled || !autoPlay || hovered || expanded || views.length < 2) return undefined;
73
+ const id = setTimeout(() => {
74
+ setOwnView((v) => {
75
+ const next = views[(views.indexOf(v) + 1) % views.length];
76
+ onViewChangeRef.current?.(next);
77
+ return next;
78
+ });
79
+ }, interval);
80
+ return () => clearTimeout(id);
81
+ }, [controlled, autoPlay, hovered, expanded, views, interval, ownView]);
82
+
83
+ // A new view always starts compact.
84
+ useEffect(() => setExpanded(false), [current]);
85
+
86
+ // Live countdown while the timer is showing.
87
+ useEffect(() => {
88
+ if (current !== "timer") return undefined;
89
+ const id = setInterval(() => setRemaining((s) => (s > 0 ? s - 1 : timerSeconds)), 1000);
90
+ return () => clearInterval(id);
91
+ }, [current, timerSeconds]);
92
+
93
+ const sizeKey = current === "music" && expanded ? "musicExpanded" : current;
94
+ const size = SIZES[sizeKey] ?? SIZES.idle;
95
+ const canExpand = current === "music";
96
+
97
+ const content = {
98
+ idle: <span className="sr-only">Idle</span>,
99
+ music: (
100
+ <div className="flex h-full w-full items-center justify-between pl-1.5 pr-4">
101
+ <img src={music.cover} alt="" className="h-[1.85rem] w-[1.85rem] rounded-full object-cover" draggable="false" />
102
+ <span className="sr-only">Now playing {music.title} by {music.artist}</span>
103
+ <Waveform color={accentColor} playing={playing} moving={moving} />
104
+ </div>
105
+ ),
106
+ musicExpanded: (
107
+ <div className="flex h-full w-full flex-col justify-between p-5">
108
+ <div className="flex items-center gap-3.5">
109
+ <img src={music.cover} alt="" className="h-14 w-14 rounded-2xl object-cover" draggable="false" />
110
+ <div className="min-w-0 flex-1">
111
+ <p className="truncate text-[0.95rem] font-semibold">{music.title}</p>
112
+ <p className="truncate text-[0.8rem] text-white/50">{music.artist}</p>
113
+ </div>
114
+ <Waveform color={accentColor} playing={playing} moving={moving} />
115
+ </div>
116
+ <div>
117
+ <div className="h-1 overflow-hidden rounded-full bg-white/15">
118
+ <motion.div
119
+ className="h-full rounded-full"
120
+ style={{ backgroundColor: accentColor }}
121
+ initial={{ width: "32%" }}
122
+ animate={{ width: playing && moving ? "78%" : "32%" }}
123
+ transition={{ duration: playing && moving ? 20 : 0.3, ease: "linear" }}
124
+ />
125
+ </div>
126
+ <div className="mt-3 flex items-center justify-center gap-7">
127
+ <button type="button" aria-label="Previous" onClick={(e) => e.stopPropagation()} className="text-white/70 transition hover:text-white">
128
+ <SkipBack className="h-5 w-5" fill="currentColor" />
129
+ </button>
130
+ <button
131
+ type="button"
132
+ aria-label={playing ? "Pause" : "Play"}
133
+ onClick={(e) => {
134
+ e.stopPropagation();
135
+ setPlaying((p) => !p);
136
+ }}
137
+ className="text-white transition hover:scale-105"
138
+ >
139
+ {playing ? <Pause className="h-7 w-7" fill="currentColor" /> : <Play className="h-7 w-7" fill="currentColor" />}
140
+ </button>
141
+ <button type="button" aria-label="Next" onClick={(e) => e.stopPropagation()} className="text-white/70 transition hover:text-white">
142
+ <SkipForward className="h-5 w-5" fill="currentColor" />
143
+ </button>
144
+ </div>
145
+ </div>
146
+ </div>
147
+ ),
148
+ call: (
149
+ <div className="flex h-full w-full items-center gap-3 pl-2.5 pr-2.5">
150
+ <img src={call.avatar} alt="" className="h-[3.4rem] w-[3.4rem] rounded-full object-cover" draggable="false" />
151
+ <div className="min-w-0 flex-1">
152
+ <p className="truncate text-[0.72rem] text-white/50">{call.subtitle}</p>
153
+ <p className="truncate text-[0.95rem] font-semibold">{call.name}</p>
154
+ </div>
155
+ <button type="button" aria-label="Decline" onClick={(e) => e.stopPropagation()} className="grid h-11 w-11 place-items-center rounded-full bg-[#ff453a] transition hover:brightness-110">
156
+ <PhoneOff className="h-[1.1rem] w-[1.1rem]" />
157
+ </button>
158
+ <button type="button" aria-label="Accept" onClick={(e) => e.stopPropagation()} className="grid h-11 w-11 place-items-center rounded-full bg-[#30d158] transition hover:brightness-110">
159
+ <Phone className="h-[1.1rem] w-[1.1rem]" fill="currentColor" />
160
+ </button>
161
+ </div>
162
+ ),
163
+ timer: (
164
+ <div className="flex h-full w-full items-center justify-between pl-3 pr-4">
165
+ <span className="grid h-[1.85rem] w-[1.85rem] place-items-center rounded-full bg-[#ff9f0a]/20 text-[#ff9f0a]">
166
+ <Timer className="h-4 w-4" />
167
+ </span>
168
+ <span className="font-mono text-[0.95rem] font-semibold tabular-nums text-[#ff9f0a]" aria-label={`Timer ${formatTime(remaining)} left`}>
169
+ {formatTime(remaining)}
170
+ </span>
171
+ </div>
172
+ ),
173
+ notification: (
174
+ <div className="flex h-full w-full items-center gap-3 pl-2.5 pr-5">
175
+ <span className="grid h-[3.4rem] w-[3.4rem] shrink-0 place-items-center rounded-full" style={{ backgroundColor: accentColor }}>
176
+ <MessageCircle className="h-6 w-6 text-black" fill="currentColor" />
177
+ </span>
178
+ <div className="min-w-0 flex-1">
179
+ <p className="flex items-center justify-between gap-2 text-[0.72rem] text-white/50">
180
+ <span className="truncate">{notification.app}</span>
181
+ <span className="shrink-0">now</span>
182
+ </p>
183
+ <p className="truncate text-[0.9rem] font-semibold">{notification.title}</p>
184
+ <p className="truncate text-[0.78rem] text-white/70">{notification.message}</p>
185
+ </div>
186
+ </div>
187
+ ),
188
+ };
189
+
190
+ return (
191
+ <div className={`flex w-full justify-center ${className}`}>
192
+ <motion.div
193
+ role="status"
194
+ aria-live="polite"
195
+ aria-label="Dynamic island"
196
+ onClick={canExpand ? () => setExpanded((e) => !e) : undefined}
197
+ onPointerEnter={(e) => e.pointerType === "mouse" && setHovered(true)}
198
+ onPointerLeave={() => setHovered(false)}
199
+ className={`relative max-w-[calc(100vw-2rem)] overflow-hidden bg-black text-white shadow-[0_18px_40px_-16px_rgba(0,0,0,0.65),inset_0_0_0_1px_rgba(255,255,255,0.06)] ${canExpand ? "cursor-pointer" : ""}`}
200
+ initial={false}
201
+ animate={size}
202
+ transition={moving ? spring : { duration: 0 }}
203
+ >
204
+ <AnimatePresence initial={false} mode="popLayout">
205
+ <motion.div
206
+ key={sizeKey}
207
+ className="absolute inset-0 flex"
208
+ initial={moving ? { opacity: 0, scale: 0.92, filter: "blur(6px)" } : { opacity: 0 }}
209
+ animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
210
+ exit={moving ? { opacity: 0, scale: 0.92, filter: "blur(6px)" } : { opacity: 0 }}
211
+ transition={{ duration: 0.28, ease: [0.22, 1, 0.36, 1], delay: moving ? 0.06 : 0 }}
212
+ >
213
+ {content[sizeKey]}
214
+ </motion.div>
215
+ </AnimatePresence>
216
+ </motion.div>
217
+ </div>
218
+ );
219
+ };
220
+
221
+ export default DynamicIsland;
@@ -0,0 +1,154 @@
1
+ import React, { useRef, useState } from "react";
2
+ import { AnimatePresence, motion, useAnimationControls, useMotionValue, useReducedMotion, useSpring, useTransform } from "framer-motion";
3
+ import { Compass, House, Image, Mail, MessageCircle, Music, Settings } from "lucide-react";
4
+
5
+ /*
6
+ * Magnify Dock: a glass dock whose icons swell toward the pointer, like the
7
+ * macOS dock. Hover shows a label, a click bounces the icon and marks it open.
8
+ * Sizes are in rem so the dock scales with the page's font size.
9
+ */
10
+
11
+ const DEFAULT_ITEMS = [
12
+ { label: "Home", icon: House, color: "linear-gradient(160deg,#60a5fa,#2563eb)" },
13
+ { label: "Explore", icon: Compass, color: "linear-gradient(160deg,#a78bfa,#7c3aed)" },
14
+ { label: "Music", icon: Music, color: "linear-gradient(160deg,#fb7185,#e11d48)" },
15
+ { label: "Photos", icon: Image, color: "linear-gradient(160deg,#fcd34d,#f97316)" },
16
+ { label: "Messages", icon: MessageCircle, color: "linear-gradient(160deg,#4ade80,#16a34a)" },
17
+ { label: "Mail", icon: Mail, color: "linear-gradient(160deg,#38bdf8,#0369a1)" },
18
+ { type: "separator" },
19
+ { label: "Settings", icon: Settings, color: "linear-gradient(160deg,#a1a1aa,#52525b)" },
20
+ ];
21
+
22
+ const THEMES = {
23
+ dark: {
24
+ panel: "border-white/10 bg-neutral-900/75 shadow-[0_20px_50px_-20px_rgba(0,0,0,0.7),inset_0_1px_0_rgba(255,255,255,0.08)]",
25
+ glassTile: "bg-white/10 text-white",
26
+ label: "bg-neutral-900/90 text-white border-white/10",
27
+ separator: "bg-white/15",
28
+ dot: "bg-white/80",
29
+ },
30
+ light: {
31
+ panel: "border-black/10 bg-white/70 shadow-[0_20px_50px_-24px_rgba(0,0,0,0.35),inset_0_1px_0_rgba(255,255,255,0.9)]",
32
+ glassTile: "bg-black/[0.06] text-neutral-800",
33
+ label: "bg-white/95 text-neutral-900 border-black/10",
34
+ separator: "bg-black/15",
35
+ dot: "bg-neutral-800/80",
36
+ },
37
+ };
38
+
39
+ function DockItem({ item, index, mouseX, baseSize, magnification, distance, theme, tileStyle, showLabels, open, onSelect }) {
40
+ const ref = useRef(null);
41
+ const [hovered, setHovered] = useState(false);
42
+ const controls = useAnimationControls();
43
+ const t = THEMES[theme] ?? THEMES.dark;
44
+
45
+ // Distance from the pointer to this icon's centre, mapped to a size factor.
46
+ const offset = useTransform(mouseX, (x) => {
47
+ const rect = ref.current?.getBoundingClientRect();
48
+ return rect ? x - (rect.left + rect.width / 2) : Infinity;
49
+ });
50
+ const target = useTransform(offset, [-distance, 0, distance], [1, magnification, 1]);
51
+ const factor = useSpring(target, { mass: 0.1, stiffness: 170, damping: 14 });
52
+ const size = useTransform(factor, (f) => `${f * baseSize}rem`);
53
+
54
+ const Icon = item.icon;
55
+ const Tag = item.href ? motion.a : motion.button;
56
+
57
+ return (
58
+ <motion.div className="relative flex flex-col items-center" style={{ width: size }}>
59
+ <AnimatePresence>
60
+ {showLabels && hovered && (
61
+ <motion.span
62
+ className={`pointer-events-none absolute bottom-full mb-3 whitespace-nowrap rounded-lg border px-2.5 py-1 text-[0.75rem] font-medium backdrop-blur-md ${t.label}`}
63
+ initial={{ opacity: 0, y: 6, scale: 0.9 }}
64
+ animate={{ opacity: 1, y: 0, scale: 1 }}
65
+ exit={{ opacity: 0, y: 4, scale: 0.95 }}
66
+ transition={{ duration: 0.16 }}
67
+ >
68
+ {item.label}
69
+ </motion.span>
70
+ )}
71
+ </AnimatePresence>
72
+ <Tag
73
+ ref={ref}
74
+ type={item.href ? undefined : "button"}
75
+ href={item.href}
76
+ aria-label={item.label}
77
+ animate={controls}
78
+ onClick={(e) => {
79
+ controls.start({ y: [0, -14, 0, -5, 0], transition: { duration: 0.7, ease: "easeOut" } });
80
+ onSelect(index, item, e);
81
+ }}
82
+ onPointerEnter={(e) => e.pointerType === "mouse" && setHovered(true)}
83
+ onPointerLeave={() => setHovered(false)}
84
+ onFocus={() => setHovered(true)}
85
+ onBlur={() => setHovered(false)}
86
+ className={`grid aspect-square w-full place-items-center rounded-[24%] outline-none transition-shadow focus-visible:ring-2 focus-visible:ring-white/70 ${tileStyle === "glass" ? t.glassTile : "text-white shadow-[inset_0_1px_0_rgba(255,255,255,0.35),0_6px_14px_-6px_rgba(0,0,0,0.5)]"}`}
87
+ style={tileStyle === "glass" ? undefined : { background: item.color }}
88
+ >
89
+ {Icon && <Icon className="h-[48%] w-[48%]" strokeWidth={2} />}
90
+ </Tag>
91
+ <span className={`absolute -bottom-[0.45rem] h-1 w-1 rounded-full transition-opacity ${t.dot} ${open ? "opacity-100" : "opacity-0"}`} />
92
+ </motion.div>
93
+ );
94
+ }
95
+
96
+ const MagnifyDock = ({
97
+ items = DEFAULT_ITEMS,
98
+ baseSize = 2.75,
99
+ magnification = 1.7,
100
+ distance = 140,
101
+ theme = "dark",
102
+ tileStyle = "color",
103
+ showLabels = true,
104
+ defaultOpen = [0],
105
+ onSelect,
106
+ className = "",
107
+ }) => {
108
+ const mouseX = useMotionValue(Infinity);
109
+ const [open, setOpen] = useState(() => new Set(defaultOpen));
110
+ const reduced = useReducedMotion();
111
+ const t = THEMES[theme] ?? THEMES.dark;
112
+ const reset = () => mouseX.set(Infinity);
113
+
114
+ const select = (index, item, e) => {
115
+ setOpen((prev) => new Set(prev).add(index));
116
+ onSelect?.(item, index, e);
117
+ };
118
+
119
+ return (
120
+ <nav aria-label="Dock" className={`flex w-full justify-center ${className}`}>
121
+ <div
122
+ onPointerMove={(e) => mouseX.set(e.clientX)}
123
+ onPointerLeave={reset}
124
+ onPointerUp={(e) => e.pointerType !== "mouse" && reset()}
125
+ onPointerCancel={reset}
126
+ className={`flex max-w-full items-end gap-[0.45rem] rounded-[1.35rem] border px-[0.55rem] pb-[0.6rem] pt-[0.55rem] backdrop-blur-xl ${t.panel}`}
127
+ style={{ height: `${baseSize + 1.15}rem`, touchAction: "pan-y" }}
128
+ >
129
+ {items.map((item, i) =>
130
+ item.type === "separator" ? (
131
+ <span key={`sep-${i}`} aria-hidden="true" className={`mx-[0.2rem] w-px self-stretch ${t.separator}`} />
132
+ ) : (
133
+ <DockItem
134
+ key={item.label ?? i}
135
+ item={item}
136
+ index={i}
137
+ mouseX={mouseX}
138
+ baseSize={baseSize}
139
+ magnification={reduced ? 1 : magnification}
140
+ distance={distance}
141
+ theme={theme}
142
+ tileStyle={tileStyle}
143
+ showLabels={showLabels}
144
+ open={open.has(i)}
145
+ onSelect={select}
146
+ />
147
+ )
148
+ )}
149
+ </div>
150
+ </nav>
151
+ );
152
+ };
153
+
154
+ export default MagnifyDock;
@@ -0,0 +1,304 @@
1
+ import React, { useEffect, useMemo, useRef, useState } from "react";
2
+ import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
3
+ import { CircleCheck, LoaderCircle, Printer, RotateCcw } from "lucide-react";
4
+
5
+ /*
6
+ * Receipt Printer: a little thermal printer that processes a payment, feeds a
7
+ * receipt out of its slot and settles on "Payment complete".
8
+ *
9
+ * Runs on its own (autoPlay) or follows a controlled `stage`:
10
+ * "processing" → "printing" → "complete"
11
+ */
12
+
13
+ const defaultItems = [
14
+ { name: "Iced Latte", qty: 2, price: 4.5 },
15
+ { name: "Blueberry Muffin", qty: 1, price: 3.25 },
16
+ { name: "Avocado Toast", qty: 1, price: 8.9 },
17
+ { name: "Sparkling Water", qty: 1, price: 2 },
18
+ ];
19
+
20
+ const STATUS = {
21
+ processing: { label: "Processing payment", Icon: LoaderCircle },
22
+ printing: { label: "Printing receipt", Icon: Printer },
23
+ complete: { label: "Payment complete", Icon: CircleCheck },
24
+ };
25
+
26
+ const ease = [0.22, 1, 0.36, 1];
27
+
28
+ /*
29
+ * Keyframes for a "stepped" feed: the paper advances in short bursts with a
30
+ * pause after each one, like a thermal printer printing line by line.
31
+ */
32
+ function steppedFeed(steps = 9, moveShare = 0.62) {
33
+ const y = ["-100%"];
34
+ const times = [0];
35
+ for (let i = 1; i <= steps; i++) {
36
+ // Ease the step sizes so the paper starts slow and speeds up a little.
37
+ const progress = 1 - Math.pow(1 - i / steps, 1.35);
38
+ const at = -100 + progress * 100;
39
+ const start = (i - 1) / steps;
40
+ const end = i / steps;
41
+ y.push(`${at.toFixed(2)}%`, `${at.toFixed(2)}%`);
42
+ times.push(start + (end - start) * moveShare, end);
43
+ }
44
+ return { y, times };
45
+ }
46
+
47
+ // Bar widths derived from the order id, so the same order always gets the same barcode.
48
+ function barcodeBars(code, count = 38) {
49
+ const seed = [...String(code)].reduce((sum, ch, i) => sum + ch.charCodeAt(0) * (i + 7), 0);
50
+ return Array.from({ length: count }, (_, i) => 1 + ((seed * (i + 3) * 2654435761) >>> 0) % 3);
51
+ }
52
+
53
+ const money = (value, currency) => `${currency}${value.toFixed(2)}`;
54
+
55
+ // Zig-zag tear along the bottom edge of the paper.
56
+ const tearMask = {
57
+ WebkitMask:
58
+ "linear-gradient(#000 0 0) top / 100% calc(100% - 7px) no-repeat, conic-gradient(from -45deg at bottom, #0000, #000 1deg 89deg, #0000 90deg) bottom / 14px 7px repeat-x",
59
+ mask: "linear-gradient(#000 0 0) top / 100% calc(100% - 7px) no-repeat, conic-gradient(from -45deg at bottom, #0000, #000 1deg 89deg, #0000 90deg) bottom / 14px 7px repeat-x",
60
+ };
61
+
62
+ function Receipt({ store, address, items, currency, taxRate, orderId, date }) {
63
+ const subtotal = items.reduce((sum, item) => sum + item.qty * item.price, 0);
64
+ const tax = subtotal * taxRate;
65
+ const bars = useMemo(() => barcodeBars(orderId), [orderId]);
66
+
67
+ return (
68
+ <article
69
+ className="relative bg-[#fbfaf5] px-5 pb-8 pt-5 font-mono text-[0.72rem] leading-relaxed text-zinc-800 shadow-[0_18px_30px_-18px_rgba(0,0,0,0.6)]"
70
+ style={tearMask}
71
+ >
72
+ {/* faint thermal-paper grain */}
73
+ <div
74
+ aria-hidden="true"
75
+ className="pointer-events-none absolute inset-0 opacity-[0.35]"
76
+ style={{ backgroundImage: "repeating-linear-gradient(0deg, rgba(0,0,0,0.035) 0 1px, transparent 1px 3px)" }}
77
+ />
78
+ <div className="relative">
79
+ <p className="text-center text-[0.85rem] font-bold uppercase tracking-[0.18em]">{store}</p>
80
+ {address && <p className="mt-0.5 text-center text-[0.65rem] text-zinc-500">{address}</p>}
81
+ <p className="mt-0.5 text-center text-[0.65rem] text-zinc-500">{date}</p>
82
+
83
+ <div className="my-3 border-t border-dashed border-zinc-400" />
84
+
85
+ <ul className="space-y-1">
86
+ {items.map((item, i) => (
87
+ <li key={i} className="flex justify-between gap-3">
88
+ <span className="min-w-0 truncate">
89
+ {item.qty} × {item.name}
90
+ </span>
91
+ <span className="shrink-0 tabular-nums">{money(item.qty * item.price, currency)}</span>
92
+ </li>
93
+ ))}
94
+ </ul>
95
+
96
+ <div className="my-3 border-t border-dashed border-zinc-400" />
97
+
98
+ <div className="space-y-0.5 text-zinc-600">
99
+ <p className="flex justify-between"><span>Subtotal</span><span className="tabular-nums">{money(subtotal, currency)}</span></p>
100
+ <p className="flex justify-between"><span>Tax {Math.round(taxRate * 100)}%</span><span className="tabular-nums">{money(tax, currency)}</span></p>
101
+ </div>
102
+ <p className="mt-2 flex justify-between text-[0.85rem] font-bold">
103
+ <span>TOTAL</span>
104
+ <span className="tabular-nums">{money(subtotal + tax, currency)}</span>
105
+ </p>
106
+
107
+ <div className="my-3 border-t border-dashed border-zinc-400" />
108
+
109
+ <div aria-hidden="true" className="flex h-9 items-stretch justify-center gap-[1.5px]">
110
+ {bars.map((w, i) => (
111
+ <span key={i} className="bg-zinc-800" style={{ width: w }} />
112
+ ))}
113
+ </div>
114
+ <p className="mt-1 text-center text-[0.62rem] tracking-[0.3em] text-zinc-500">{orderId}</p>
115
+ <p className="mt-3 text-center text-[0.65rem] font-semibold uppercase tracking-[0.25em]">Thank you</p>
116
+ </div>
117
+ </article>
118
+ );
119
+ }
120
+
121
+ const ReceiptPrinter = ({
122
+ store = "ApexUI Café",
123
+ address = "221B Baker Street",
124
+ items = defaultItems,
125
+ currency = "$",
126
+ taxRate = 0.08,
127
+ orderId = "AX-20491",
128
+ date,
129
+ stage,
130
+ autoPlay = true,
131
+ feed = "stepped",
132
+ processingTime = 1600,
133
+ printTime = 2200,
134
+ accentColor = "#a3e635",
135
+ onComplete,
136
+ className = "",
137
+ }) => {
138
+ const controlled = stage !== undefined;
139
+ const [ownStage, setOwnStage] = useState(autoPlay ? "processing" : "complete");
140
+ const [run, setRun] = useState(0);
141
+ const current = controlled ? stage : ownStage;
142
+ const reduceMotion = useReducedMotion();
143
+ const onCompleteRef = useRef(onComplete);
144
+ onCompleteRef.current = onComplete;
145
+
146
+ // Self-running sequence (skipped when the parent controls the stage).
147
+ useEffect(() => {
148
+ if (controlled || (!autoPlay && run === 0)) return undefined;
149
+ setOwnStage("processing");
150
+ const toPrint = setTimeout(() => setOwnStage("printing"), processingTime);
151
+ const toDone = setTimeout(() => {
152
+ setOwnStage("complete");
153
+ onCompleteRef.current?.();
154
+ }, processingTime + printTime);
155
+ return () => {
156
+ clearTimeout(toPrint);
157
+ clearTimeout(toDone);
158
+ };
159
+ }, [controlled, autoPlay, run, processingTime, printTime]);
160
+
161
+ const printedAt = useMemo(
162
+ () =>
163
+ date ??
164
+ new Date().toLocaleString("en-US", { month: "short", day: "numeric", year: "numeric", hour: "numeric", minute: "2-digit" }),
165
+ // A fresh timestamp for every print run.
166
+ // eslint-disable-next-line react-hooks/exhaustive-deps
167
+ [date, run]
168
+ );
169
+
170
+ const stepped = useMemo(() => steppedFeed(), []);
171
+ const moving = !reduceMotion;
172
+ const paperY =
173
+ current === "processing"
174
+ ? "-100%"
175
+ : current === "printing" && moving
176
+ ? feed === "stepped"
177
+ ? stepped.y
178
+ : "0%"
179
+ : "0%";
180
+ const paperTransition =
181
+ current === "printing" && moving
182
+ ? feed === "stepped"
183
+ ? { duration: printTime / 1000, ease: "linear", times: stepped.times }
184
+ : { duration: printTime / 1000, ease: [0.65, 0, 0.35, 1] }
185
+ : { duration: 0 };
186
+
187
+ const { label, Icon } = STATUS[current] ?? STATUS.processing;
188
+ const itemCount = items.reduce((sum, item) => sum + item.qty, 0);
189
+
190
+ return (
191
+ <section aria-label="Receipt printer" className={`relative isolate flex w-[20rem] max-w-full flex-col items-center ${className}`}>
192
+ {/* Machine */}
193
+ <div
194
+ className="relative z-20 w-full rounded-[1.75rem] border border-white/10 bg-[#1b1b20] p-3 pb-8 shadow-[0_30px_60px_-30px_rgba(0,0,0,0.85),inset_0_1px_0_rgba(255,255,255,0.08),inset_0_-2px_0_rgba(0,0,0,0.5)]"
195
+ >
196
+ {/* matte plastic texture */}
197
+ <div
198
+ aria-hidden="true"
199
+ className="pointer-events-none absolute inset-0 rounded-[inherit] opacity-[0.12]"
200
+ style={{ backgroundImage: "radial-gradient(rgba(255,255,255,0.7) 0.5px, transparent 0.6px)", backgroundSize: "4px 4px" }}
201
+ />
202
+
203
+ {/* Top row: brand, LED and reprint */}
204
+ <div className="relative flex h-10 items-center justify-between px-2">
205
+ <span className="font-mono text-[0.6rem] uppercase tracking-[0.3em] text-white/40">Apex · Print</span>
206
+ <div className="flex items-center gap-2">
207
+ <span
208
+ aria-hidden="true"
209
+ className={`h-2 w-2 rounded-full ${current === "printing" && moving ? "animate-pulse" : ""}`}
210
+ style={{
211
+ backgroundColor: current === "complete" ? accentColor : current === "printing" ? accentColor : "#f59e0b",
212
+ boxShadow: `0 0 10px ${current === "processing" ? "#f59e0b" : accentColor}`,
213
+ }}
214
+ />
215
+ {!controlled && (
216
+ <button
217
+ type="button"
218
+ onClick={() => setRun((r) => r + 1)}
219
+ disabled={current !== "complete"}
220
+ aria-label="Print again"
221
+ title="Print again"
222
+ className="flex h-7 w-7 items-center justify-center rounded-full border border-white/10 bg-white/5 text-white/60 transition hover:text-white disabled:pointer-events-none disabled:opacity-30"
223
+ >
224
+ <RotateCcw className="h-3.5 w-3.5" />
225
+ </button>
226
+ )}
227
+ </div>
228
+ </div>
229
+
230
+ {/* Screen */}
231
+ <div className="relative overflow-hidden rounded-[1.1rem] border border-black/60 bg-[#0b0e0c] px-4 py-3.5 shadow-[inset_0_2px_12px_rgba(0,0,0,0.8)]">
232
+ <div className="flex items-center gap-2.5" role="status" aria-live="polite">
233
+ <span className="relative grid h-5 w-5 shrink-0 place-items-center" style={{ color: current === "processing" ? "#f59e0b" : accentColor }}>
234
+ <AnimatePresence initial={false} mode="popLayout">
235
+ <motion.span
236
+ key={current}
237
+ initial={moving ? { opacity: 0, scale: 0.7 } : false}
238
+ animate={{ opacity: 1, scale: 1 }}
239
+ exit={moving ? { opacity: 0, scale: 0.7 } : { opacity: 0 }}
240
+ transition={{ duration: 0.2, ease }}
241
+ className="grid place-items-center"
242
+ >
243
+ <Icon className={`h-[18px] w-[18px] ${current === "processing" && moving ? "animate-spin" : ""}`} strokeWidth={2.2} />
244
+ </motion.span>
245
+ </AnimatePresence>
246
+ </span>
247
+ <div className="relative h-4 min-w-0 flex-1 overflow-hidden">
248
+ <AnimatePresence initial={false} mode="popLayout">
249
+ <motion.p
250
+ key={current}
251
+ initial={moving ? { opacity: 0, y: 8 } : false}
252
+ animate={{ opacity: 1, y: 0 }}
253
+ exit={moving ? { opacity: 0, y: -8 } : { opacity: 0 }}
254
+ transition={{ duration: 0.22, ease }}
255
+ className="truncate text-xs font-medium leading-4 text-zinc-200"
256
+ >
257
+ {label}
258
+ </motion.p>
259
+ </AnimatePresence>
260
+ </div>
261
+ </div>
262
+ <div className="mt-2.5 flex items-center justify-between font-mono text-[0.62rem] text-zinc-500">
263
+ <span>#{orderId}</span>
264
+ <span>{itemCount} items</span>
265
+ </div>
266
+ {/* Progress while printing */}
267
+ <div className="mt-2 h-1 overflow-hidden rounded-full bg-white/5">
268
+ <motion.div
269
+ key={`${run}-${current}`}
270
+ className="h-full rounded-full"
271
+ style={{ backgroundColor: current === "processing" ? "#f59e0b" : accentColor }}
272
+ initial={{ width: current === "printing" ? "0%" : current === "complete" ? "100%" : "35%" }}
273
+ animate={{ width: current === "processing" ? ["15%", "60%", "15%"] : "100%" }}
274
+ transition={
275
+ current === "processing"
276
+ ? { duration: 1.4, repeat: Infinity, ease: "easeInOut" }
277
+ : { duration: current === "printing" && moving ? printTime / 1000 : 0, ease: "linear" }
278
+ }
279
+ />
280
+ </div>
281
+ </div>
282
+
283
+ {/* Paper slot */}
284
+ <div aria-hidden="true" className="absolute inset-x-7 bottom-3 h-2 rounded-full bg-black shadow-[inset_0_2px_3px_rgba(0,0,0,0.9),0_1px_0_rgba(255,255,255,0.06)]" />
285
+ </div>
286
+
287
+ {/* Paper coming out of the slot */}
288
+ <div className="relative z-10 -mt-4 w-[86%] overflow-hidden px-1 pb-4 pt-0">
289
+ <div aria-hidden="true" className="pointer-events-none absolute inset-x-1 top-0 z-10 h-3 bg-gradient-to-b from-black/45 to-transparent" />
290
+ <motion.div
291
+ key={run}
292
+ initial={{ y: "-100%" }}
293
+ animate={{ y: paperY }}
294
+ transition={paperTransition}
295
+ aria-hidden={current !== "complete"}
296
+ >
297
+ <Receipt store={store} address={address} items={items} currency={currency} taxRate={taxRate} orderId={orderId} date={printedAt} />
298
+ </motion.div>
299
+ </div>
300
+ </section>
301
+ );
302
+ };
303
+
304
+ export default ReceiptPrinter;
@@ -0,0 +1,143 @@
1
+ import React, { useEffect, useMemo, useRef } from "react";
2
+ import { AnimatePresence, motion, useInView, useReducedMotion, useSpring, useTransform } from "framer-motion";
3
+
4
+ /*
5
+ * Rolling Counter: an odometer-style number. Every digit is a wheel that
6
+ * rolls forward or back to its new value, carrying into the next wheel like a
7
+ * real odometer. Formats with Intl (separators, decimals, currency).
8
+ */
9
+
10
+ const DIGITS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
11
+ const STEP = 1.15; // wheel height per digit, in em
12
+ const DEFAULT_SPRING = { stiffness: 70, damping: 18, mass: 1 };
13
+
14
+ function WheelDigit({ digit, position }) {
15
+ // Place each digit on a loop around the wheel so the wheel can spin either way.
16
+ const y = useTransform(position, (v) => {
17
+ const off = (((digit - v) % 10) + 10) % 10;
18
+ return `${(off > 5 ? off - 10 : off) * STEP}em`;
19
+ });
20
+ return (
21
+ <motion.span className="absolute inset-0 flex items-center justify-center" style={{ y }}>
22
+ {digit}
23
+ </motion.span>
24
+ );
25
+ }
26
+
27
+ function Wheel({ target, spring, moving }) {
28
+ // New wheels start at 0 and roll in.
29
+ const position = useSpring(moving ? 0 : target, spring);
30
+ useEffect(() => {
31
+ if (!moving) {
32
+ position.jump(target);
33
+ return;
34
+ }
35
+ // Big jumps skip ahead (keeping the same digit) so the wheel spins at most ~2 turns.
36
+ const current = position.get();
37
+ const gap = target - current;
38
+ if (Math.abs(gap) > 20) position.jump(current + 10 * Math.round((gap - Math.sign(gap) * 20) / 10));
39
+ position.set(target);
40
+ }, [target, moving, position]);
41
+
42
+ return (
43
+ <span
44
+ className="relative inline-block overflow-hidden"
45
+ style={{
46
+ width: "1ch",
47
+ height: `${STEP}em`,
48
+ maskImage: "linear-gradient(to bottom, transparent, #000 22%, #000 78%, transparent)",
49
+ WebkitMaskImage: "linear-gradient(to bottom, transparent, #000 22%, #000 78%, transparent)",
50
+ }}
51
+ >
52
+ {DIGITS.map((d) => (
53
+ <WheelDigit key={d} digit={d} position={position} />
54
+ ))}
55
+ </span>
56
+ );
57
+ }
58
+
59
+ const RollingCounter = ({
60
+ value = 0,
61
+ locale = "en-US",
62
+ decimals = 0,
63
+ currency,
64
+ prefix = "",
65
+ suffix = "",
66
+ fontSize = 4,
67
+ fontWeight = 700,
68
+ color = "currentColor",
69
+ startOnView = true,
70
+ spring = DEFAULT_SPRING,
71
+ className = "",
72
+ }) => {
73
+ const ref = useRef(null);
74
+ const inView = useInView(ref, { once: true, margin: "-10% 0px" });
75
+ const moving = !useReducedMotion();
76
+ const shown = startOnView && !inView ? 0 : value;
77
+
78
+ const formatter = useMemo(
79
+ () =>
80
+ new Intl.NumberFormat(locale, {
81
+ numberingSystem: "latn",
82
+ minimumFractionDigits: decimals,
83
+ maximumFractionDigits: decimals,
84
+ ...(currency ? { style: "currency", currency } : {}),
85
+ }),
86
+ [locale, decimals, currency]
87
+ );
88
+
89
+ // Whole number of the smallest unit shown, e.g. 12.34 with 2 decimals -> 1234.
90
+ const scaled = Math.round(Math.abs(shown) * 10 ** decimals);
91
+ const text = formatter.format(shown);
92
+
93
+ // Walk the formatted string from the right so each wheel keeps its place as
94
+ // the number grows. Symbols before the first digit ($, -) keep a fixed key.
95
+ const isDigit = (ch) => ch >= "0" && ch <= "9";
96
+ const firstDigit = [...text].findIndex(isDigit);
97
+ const parts = [];
98
+ let place = 0;
99
+ for (let i = text.length - 1; i >= 0; i--) {
100
+ const ch = text[i];
101
+ if (isDigit(ch)) {
102
+ parts.unshift({ key: `d${place}`, place });
103
+ place += 1;
104
+ } else {
105
+ parts.unshift({ key: i < firstDigit ? `lead${i}-${ch}` : `s${place}-${ch}`, char: ch });
106
+ }
107
+ }
108
+
109
+ return (
110
+ <span
111
+ ref={ref}
112
+ role="img"
113
+ aria-label={`${prefix}${formatter.format(value)}${suffix}`}
114
+ className={`inline-flex items-center whitespace-nowrap leading-none tabular-nums ${className}`}
115
+ style={{ fontSize: `${fontSize}rem`, fontWeight, color, fontVariantNumeric: "tabular-nums" }}
116
+ >
117
+ {prefix && <span aria-hidden="true">{prefix}</span>}
118
+ <AnimatePresence initial={false} mode="popLayout">
119
+ {parts.map((part) => (
120
+ <motion.span
121
+ key={part.key}
122
+ layout="position"
123
+ aria-hidden="true"
124
+ className="inline-flex"
125
+ initial={{ opacity: 0, y: "-0.4em" }}
126
+ animate={{ opacity: 1, y: 0 }}
127
+ exit={{ opacity: 0, y: "0.4em" }}
128
+ transition={{ duration: moving ? 0.35 : 0 }}
129
+ >
130
+ {part.char !== undefined ? (
131
+ part.char === " " || part.char === " " || part.char === " " ? " " : part.char
132
+ ) : (
133
+ <Wheel target={Math.floor(scaled / 10 ** part.place)} spring={spring} moving={moving} />
134
+ )}
135
+ </motion.span>
136
+ ))}
137
+ </AnimatePresence>
138
+ {suffix && <span aria-hidden="true">{suffix}</span>}
139
+ </span>
140
+ );
141
+ };
142
+
143
+ export default RollingCounter;
@@ -0,0 +1,132 @@
1
+ import React, { useCallback, useEffect, useRef, useState } from "react";
2
+ import { useInView, useReducedMotion } from "framer-motion";
3
+
4
+ /*
5
+ * Scramble Text: letters cycle through random characters and lock into place
6
+ * one by one, like text being decrypted. Runs when it scrolls into view, on
7
+ * hover, or on a loop. Every letter keeps its real width, so nothing jumps.
8
+ */
9
+
10
+ const DEFAULT_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789#%&*@$/<>";
11
+
12
+ // Random character, lowercased for lowercase letters so widths stay close.
13
+ const pick = (chars, ch = "") => {
14
+ const c = chars[Math.floor(Math.random() * chars.length)];
15
+ return ch !== ch.toUpperCase() ? c.toLowerCase() : c;
16
+ };
17
+
18
+ // Order in which the letters lock in.
19
+ function revealOrder(text, direction) {
20
+ const chars = [...text];
21
+ const idx = chars.map((_, i) => i).filter((i) => chars[i] !== " ");
22
+ if (direction === "end") return idx.reverse();
23
+ if (direction === "center") {
24
+ const mid = (chars.length - 1) / 2;
25
+ return idx.sort((a, b) => Math.abs(a - mid) - Math.abs(b - mid));
26
+ }
27
+ if (direction === "random") {
28
+ for (let i = idx.length - 1; i > 0; i--) {
29
+ const j = Math.floor(Math.random() * (i + 1));
30
+ [idx[i], idx[j]] = [idx[j], idx[i]];
31
+ }
32
+ }
33
+ return idx;
34
+ }
35
+
36
+ const scrambleAll = (text, chars) => [...text].map((ch) => (ch === " " ? " " : pick(chars, ch)));
37
+
38
+ const ScrambleText = ({
39
+ text = "Decrypting the future",
40
+ as: Tag = "span",
41
+ trigger = "view",
42
+ speed = 40,
43
+ revealRate = 2,
44
+ direction = "start",
45
+ characters = DEFAULT_CHARS,
46
+ accentColor = "#a3e635",
47
+ loopDelay = 2400,
48
+ className = "",
49
+ ...rest
50
+ }) => {
51
+ const ref = useRef(null);
52
+ const timers = useRef({ tick: 0, wait: 0 });
53
+ const reduced = useReducedMotion();
54
+ const inView = useInView(ref, { once: trigger === "view", margin: "-10% 0px" });
55
+ // null means "show the real text"; an array means scrambled letters, with locked ones in `locked`.
56
+ const [output, setOutput] = useState(() => (trigger === "view" && !reduced ? scrambleAll(text, characters) : null));
57
+ const [locked, setLocked] = useState(() => new Set());
58
+ const running = useRef(false);
59
+
60
+ const stop = useCallback(() => {
61
+ clearInterval(timers.current.tick);
62
+ clearTimeout(timers.current.wait);
63
+ running.current = false;
64
+ }, []);
65
+
66
+ const run = useCallback(() => {
67
+ if (reduced || running.current) return;
68
+ running.current = true;
69
+ const order = revealOrder(text, direction);
70
+ let tick = 0;
71
+ timers.current.tick = setInterval(() => {
72
+ tick += 1;
73
+ const count = Math.floor(tick / Math.max(1, revealRate));
74
+ const done = new Set(order.slice(0, count));
75
+ if (count >= order.length) {
76
+ clearInterval(timers.current.tick);
77
+ running.current = false;
78
+ setOutput(null);
79
+ setLocked(new Set());
80
+ if (trigger === "loop") timers.current.wait = setTimeout(run, loopDelay);
81
+ return;
82
+ }
83
+ setLocked(done);
84
+ setOutput([...text].map((ch, i) => (ch === " " || done.has(i) ? ch : pick(characters, ch))));
85
+ }, speed);
86
+ }, [reduced, text, direction, revealRate, speed, characters, trigger, loopDelay]);
87
+
88
+ // Start on view / loop; restart cleanly when the settings change.
89
+ useEffect(() => {
90
+ if (reduced) {
91
+ setOutput(null);
92
+ return undefined;
93
+ }
94
+ if ((trigger === "view" || trigger === "loop") && inView) run();
95
+ return stop;
96
+ }, [trigger, inView, run, stop, reduced]);
97
+
98
+ const hoverProps = trigger === "hover" ? { onPointerEnter: run, onFocus: run } : {};
99
+
100
+ // Group letters by word so lines only wrap between words.
101
+ let index = 0;
102
+ const words = text.split(" ").map((word, w) => {
103
+ const letters = [...word].map((ch) => {
104
+ const i = index++;
105
+ const shown = output ? output[i] : ch;
106
+ const scrambling = output && !locked.has(i);
107
+ return (
108
+ <span key={i} className="relative inline-block">
109
+ <span className="invisible">{ch}</span>
110
+ <span className="absolute inset-0 text-center" style={scrambling ? { color: accentColor } : undefined}>
111
+ {shown}
112
+ </span>
113
+ </span>
114
+ );
115
+ });
116
+ index += 1; // the space
117
+ return (
118
+ <React.Fragment key={w}>
119
+ {w > 0 && " "}
120
+ <span className="inline-block whitespace-nowrap">{letters}</span>
121
+ </React.Fragment>
122
+ );
123
+ });
124
+
125
+ return (
126
+ <Tag ref={ref} aria-label={text} className={className} {...rest} {...hoverProps}>
127
+ <span aria-hidden="true">{words}</span>
128
+ </Tag>
129
+ );
130
+ };
131
+
132
+ export default ScrambleText;
package/dist/cli/index.js CHANGED
@@ -47,6 +47,12 @@ const componentMap = {
47
47
  "theme-toggle": "ThemeToggle",
48
48
  "testimonials": "Testimonials",
49
49
  "mask-view": "MaskView",
50
+ "receipt-printer": "ReceiptPrinter",
51
+ "dynamic-island": "DynamicIsland",
52
+ "magnify-dock": "MagnifyDock",
53
+ "dot-wave-background": "DotWaveBackground",
54
+ "rolling-counter": "RollingCounter",
55
+ "scramble-text": "ScrambleText",
50
56
  "water-drop-reveal": "MaskView",
51
57
  };
52
58
  // Packages only some components need, installed on top of the shared ones.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apex-ui-kit",
3
- "version": "2.1.1",
3
+ "version": "2.1.3",
4
4
  "license": "MIT",
5
5
  "description": "Apex UI Kit - A React component library for building modern user interfaces",
6
6
  "keywords": [