apex-ui-kit 2.1.2 → 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.
- package/dist/ApexUI-Kit/templates/DotWaveBackground/DotWaveBackground.jsx +189 -0
- package/dist/ApexUI-Kit/templates/DynamicIsland/DynamicIsland.jsx +221 -0
- package/dist/ApexUI-Kit/templates/MagnifyDock/MagnifyDock.jsx +154 -0
- package/dist/ApexUI-Kit/templates/RollingCounter/RollingCounter.jsx +143 -0
- package/dist/ApexUI-Kit/templates/ScrambleText/ScrambleText.jsx +132 -0
- package/dist/cli/index.js +5 -0
- package/package.json +1 -1
|
@@ -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,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
|
@@ -48,6 +48,11 @@ const componentMap = {
|
|
|
48
48
|
"testimonials": "Testimonials",
|
|
49
49
|
"mask-view": "MaskView",
|
|
50
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",
|
|
51
56
|
"water-drop-reveal": "MaskView",
|
|
52
57
|
};
|
|
53
58
|
// Packages only some components need, installed on top of the shared ones.
|