apex-ui-kit 2.1.2 → 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.
- package/dist/ApexUI-Kit/templates/AnimatedButton/AnimatedButton.jsx +251 -0
- package/dist/ApexUI-Kit/templates/AnimatedTabs/AnimatedTabs.jsx +99 -0
- package/dist/ApexUI-Kit/templates/BlurRevealText/BlurRevealText.jsx +60 -0
- package/dist/ApexUI-Kit/templates/Carousel3D/Carousel3D.jsx +222 -0
- package/dist/ApexUI-Kit/templates/DotGlobe/DotGlobe.jsx +289 -0
- 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/FaqAccordion/FaqAccordion.jsx +88 -0
- package/dist/ApexUI-Kit/templates/FlipClock/FlipClock.jsx +121 -0
- package/dist/ApexUI-Kit/templates/LiquidGradient/LiquidGradient.jsx +238 -0
- package/dist/ApexUI-Kit/templates/LoadingScreen/LoadingScreen.jsx +361 -0
- package/dist/ApexUI-Kit/templates/MagneticCursor/MagneticCursor.jsx +306 -0
- package/dist/ApexUI-Kit/templates/MagnifyDock/MagnifyDock.jsx +154 -0
- package/dist/ApexUI-Kit/templates/ProfileCard/ProfileCard.jsx +213 -0
- package/dist/ApexUI-Kit/templates/RollingCounter/RollingCounter.jsx +143 -0
- package/dist/ApexUI-Kit/templates/ScrambleText/ScrambleText.jsx +132 -0
- package/dist/ApexUI-Kit/templates/SidebarNav/SidebarNav.jsx +220 -0
- package/dist/ApexUI-Kit/templates/SpotlightGrid/SpotlightGrid.jsx +110 -0
- package/dist/ApexUI-Kit/templates/ToastStack/ToastStack.jsx +108 -0
- package/dist/ApexUI-Kit/templates/ToolTip/ToolTip.jsx +5 -3
- package/dist/ApexUI-Kit/templates/WebGLCarousel/WebGLCarousel.jsx +326 -0
- package/dist/ApexUI-Kit/templates/WorldMap/WorldMap.jsx +359 -0
- package/dist/cli/index.js +25 -0
- package/package.json +1 -1
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import React, { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
* Loading Screen: five ways to wait.
|
|
6
|
+
* orbit – dots orbit a breathing core around the percentage
|
|
7
|
+
* progress – a big counting percentage, a sliding bar and rotating status lines
|
|
8
|
+
* blocks – a grid of tiles fills in like a wave
|
|
9
|
+
* catch – a mini game: tap the orb before it fades while things load
|
|
10
|
+
* runner – a mini game: jump the blocks (Space / ↑ / tap) while things load
|
|
11
|
+
* Pass `progress` (0–100) yourself, or leave it out and it runs on its own.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const MESSAGES = ["Warming up the engines", "Fetching your components", "Polishing pixels", "Almost there"];
|
|
15
|
+
|
|
16
|
+
// Simulated progress that eases toward 100 over `duration` ms, then calls onComplete.
|
|
17
|
+
function useProgress(progress, duration, loop, onComplete) {
|
|
18
|
+
const [auto, setAuto] = useState(0);
|
|
19
|
+
const doneRef = useRef(onComplete);
|
|
20
|
+
doneRef.current = onComplete;
|
|
21
|
+
const controlled = typeof progress === "number";
|
|
22
|
+
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
if (controlled) return undefined;
|
|
25
|
+
let raf = 0;
|
|
26
|
+
let start = performance.now();
|
|
27
|
+
let fired = false;
|
|
28
|
+
const tick = (now) => {
|
|
29
|
+
const t = Math.min(1, (now - start) / duration);
|
|
30
|
+
// Fast start, slow middle, quick finish — the way real loads feel.
|
|
31
|
+
const eased = t < 0.85 ? 0.92 * (1 - Math.pow(1 - t / 0.85, 2.2)) : 0.92 + 0.08 * ((t - 0.85) / 0.15);
|
|
32
|
+
setAuto(Math.round(eased * 100));
|
|
33
|
+
if (t >= 1) {
|
|
34
|
+
if (!fired) {
|
|
35
|
+
fired = true;
|
|
36
|
+
doneRef.current?.();
|
|
37
|
+
}
|
|
38
|
+
if (loop) {
|
|
39
|
+
start = now + 900;
|
|
40
|
+
fired = false;
|
|
41
|
+
} else return;
|
|
42
|
+
}
|
|
43
|
+
raf = requestAnimationFrame(tick);
|
|
44
|
+
};
|
|
45
|
+
raf = requestAnimationFrame(tick);
|
|
46
|
+
return () => cancelAnimationFrame(raf);
|
|
47
|
+
}, [controlled, duration, loop]);
|
|
48
|
+
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
if (controlled && progress >= 100) doneRef.current?.();
|
|
51
|
+
}, [controlled, progress]);
|
|
52
|
+
|
|
53
|
+
return Math.max(0, Math.min(100, controlled ? progress : auto));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function Bar({ value, color, track }) {
|
|
57
|
+
return (
|
|
58
|
+
<div className="h-1.5 w-full overflow-hidden rounded-full" style={{ background: track }}>
|
|
59
|
+
<div className="h-full rounded-full transition-[width] duration-300 ease-out" style={{ width: `${value}%`, background: color, boxShadow: `0 0 12px ${color}` }} />
|
|
60
|
+
</div>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function Orbit({ value, color, moving }) {
|
|
65
|
+
return (
|
|
66
|
+
<div className="flex flex-col items-center">
|
|
67
|
+
<div className="relative grid h-36 w-36 place-items-center">
|
|
68
|
+
{[0, 1, 2].map((ring) => (
|
|
69
|
+
<motion.div
|
|
70
|
+
key={ring}
|
|
71
|
+
className="absolute inset-0"
|
|
72
|
+
style={{ scale: 1 - ring * 0.22, rotate: ring * 40 }}
|
|
73
|
+
// Each ring starts at its own angle so the dots never line up.
|
|
74
|
+
animate={moving ? { rotate: ring * 40 + (ring % 2 ? -360 : 360) } : undefined}
|
|
75
|
+
transition={{ duration: 2.4 + ring * 1.1, repeat: Infinity, ease: "linear" }}
|
|
76
|
+
>
|
|
77
|
+
{[0, 1, 2].map((d) => (
|
|
78
|
+
<div key={d} className="absolute inset-0" style={{ transform: `rotate(${d * 120}deg)` }}>
|
|
79
|
+
<span
|
|
80
|
+
className="absolute left-1/2 top-0 -ml-[5px] -mt-[5px] h-2.5 w-2.5 rounded-full"
|
|
81
|
+
style={{ background: color, opacity: 1 - ring * 0.25, boxShadow: `0 0 10px ${color}` }}
|
|
82
|
+
/>
|
|
83
|
+
</div>
|
|
84
|
+
))}
|
|
85
|
+
</motion.div>
|
|
86
|
+
))}
|
|
87
|
+
<motion.div
|
|
88
|
+
className="h-12 w-12 rounded-full"
|
|
89
|
+
style={{ background: `radial-gradient(circle at 35% 35%, #fff, ${color} 55%)`, boxShadow: `0 0 40px ${color}` }}
|
|
90
|
+
animate={moving ? { scale: [0.9, 1.08, 0.9] } : undefined}
|
|
91
|
+
transition={{ duration: 1.6, repeat: Infinity, ease: "easeInOut" }}
|
|
92
|
+
/>
|
|
93
|
+
</div>
|
|
94
|
+
<p className="mt-5 font-mono text-2xl font-semibold tabular-nums">{value}%</p>
|
|
95
|
+
</div>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function ProgressView({ value, color, track, moving }) {
|
|
100
|
+
const message = MESSAGES[Math.min(MESSAGES.length - 1, Math.floor(value / (100 / MESSAGES.length)))];
|
|
101
|
+
return (
|
|
102
|
+
<div className="w-[min(22rem,80vw)]">
|
|
103
|
+
<p className="font-mono text-[clamp(3.5rem,12vw,5.5rem)] font-bold leading-none tabular-nums tracking-tight">
|
|
104
|
+
{value}
|
|
105
|
+
<span className="text-[0.4em] align-top opacity-50">%</span>
|
|
106
|
+
</p>
|
|
107
|
+
<div className="mt-5">
|
|
108
|
+
<Bar value={value} color={color} track={track} />
|
|
109
|
+
</div>
|
|
110
|
+
<div className="mt-3 h-5 overflow-hidden text-sm opacity-70">
|
|
111
|
+
<AnimatePresence mode="wait" initial={false}>
|
|
112
|
+
<motion.p key={message} initial={{ y: 14, opacity: 0 }} animate={{ y: 0, opacity: 1 }} exit={{ y: -14, opacity: 0 }} transition={{ duration: moving ? 0.25 : 0 }}>
|
|
113
|
+
{message}…
|
|
114
|
+
</motion.p>
|
|
115
|
+
</AnimatePresence>
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function Blocks({ value, color, track, moving }) {
|
|
122
|
+
const N = 7;
|
|
123
|
+
const filled = Math.round((value / 100) * N * N);
|
|
124
|
+
return (
|
|
125
|
+
<div className="flex flex-col items-center">
|
|
126
|
+
<div className="grid gap-1.5" style={{ gridTemplateColumns: `repeat(${N}, 1fr)` }}>
|
|
127
|
+
{Array.from({ length: N * N }, (_, i) => {
|
|
128
|
+
const on = i < filled;
|
|
129
|
+
const r = Math.floor(i / N);
|
|
130
|
+
const c = i % N;
|
|
131
|
+
return (
|
|
132
|
+
<motion.span
|
|
133
|
+
key={i}
|
|
134
|
+
className="h-4 w-4 rounded-[5px] sm:h-5 sm:w-5"
|
|
135
|
+
style={{ background: on ? color : track }}
|
|
136
|
+
animate={moving ? { scale: on ? [1, 0.7, 1] : 1, opacity: on ? 1 : [0.5, 1, 0.5] } : undefined}
|
|
137
|
+
transition={{ duration: 1.6, repeat: Infinity, delay: (r + c) * 0.07, ease: "easeInOut" }}
|
|
138
|
+
/>
|
|
139
|
+
);
|
|
140
|
+
})}
|
|
141
|
+
</div>
|
|
142
|
+
<p className="mt-5 font-mono text-lg font-semibold tabular-nums">{value}%</p>
|
|
143
|
+
</div>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function CatchGame({ value, color, track }) {
|
|
148
|
+
const [score, setScore] = useState(0);
|
|
149
|
+
const [orb, setOrb] = useState({ id: 0, x: 50, y: 50 });
|
|
150
|
+
|
|
151
|
+
useEffect(() => {
|
|
152
|
+
const id = setTimeout(() => {
|
|
153
|
+
setScore(0);
|
|
154
|
+
setOrb((o) => ({ id: o.id + 1, x: 10 + Math.random() * 80, y: 15 + Math.random() * 70 }));
|
|
155
|
+
}, 1400);
|
|
156
|
+
return () => clearTimeout(id);
|
|
157
|
+
}, [orb.id]);
|
|
158
|
+
|
|
159
|
+
return (
|
|
160
|
+
<div className="w-[min(22rem,84vw)]">
|
|
161
|
+
<div className="mb-2 flex items-center justify-between font-mono text-[0.7rem] uppercase tracking-[0.16em] opacity-70">
|
|
162
|
+
<span>Catch the orb</span>
|
|
163
|
+
<span>Score {score}</span>
|
|
164
|
+
</div>
|
|
165
|
+
<div className="relative h-40 overflow-hidden rounded-2xl border" style={{ borderColor: track, backgroundImage: `radial-gradient(${track} 1px, transparent 1px)`, backgroundSize: "14px 14px" }}>
|
|
166
|
+
<AnimatePresence>
|
|
167
|
+
<motion.button
|
|
168
|
+
key={orb.id}
|
|
169
|
+
type="button"
|
|
170
|
+
aria-label="Catch the orb"
|
|
171
|
+
onPointerDown={() => {
|
|
172
|
+
setScore((s) => s + 1);
|
|
173
|
+
setOrb((o) => ({ id: o.id + 1, x: 10 + Math.random() * 80, y: 15 + Math.random() * 70 }));
|
|
174
|
+
}}
|
|
175
|
+
className="absolute h-9 w-9 rounded-full outline-none"
|
|
176
|
+
style={{ left: `${orb.x}%`, top: `${orb.y}%`, marginLeft: -18, marginTop: -18, background: color, boxShadow: `0 0 24px ${color}` }}
|
|
177
|
+
initial={{ scale: 0, opacity: 0 }}
|
|
178
|
+
animate={{ scale: [0, 1.15, 1, 0.55], opacity: [0, 1, 1, 0.7] }}
|
|
179
|
+
exit={{ scale: 1.8, opacity: 0, transition: { duration: 0.25 } }}
|
|
180
|
+
transition={{ duration: 1.4, times: [0, 0.15, 0.3, 1], ease: "easeOut" }}
|
|
181
|
+
/>
|
|
182
|
+
</AnimatePresence>
|
|
183
|
+
</div>
|
|
184
|
+
<div className="mt-4 flex items-center gap-3">
|
|
185
|
+
<Bar value={value} color={color} track={track} />
|
|
186
|
+
<span className="font-mono text-xs tabular-nums opacity-70">{value}%</span>
|
|
187
|
+
</div>
|
|
188
|
+
</div>
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function RunnerGame({ value, color, track, ink }) {
|
|
193
|
+
const canvasRef = useRef(null);
|
|
194
|
+
const [score, setScore] = useState(0);
|
|
195
|
+
const [best, setBest] = useState(0);
|
|
196
|
+
const jumpRef = useRef(() => {});
|
|
197
|
+
|
|
198
|
+
useEffect(() => {
|
|
199
|
+
const canvas = canvasRef.current;
|
|
200
|
+
const ctx = canvas.getContext("2d");
|
|
201
|
+
let raf = 0;
|
|
202
|
+
let last = performance.now();
|
|
203
|
+
let w = 0;
|
|
204
|
+
let h = 0;
|
|
205
|
+
const g = { y: 0, vy: 0, obstacles: [], speed: 220, spawn: 0.9, score: 0, flash: 0 };
|
|
206
|
+
|
|
207
|
+
const resize = () => {
|
|
208
|
+
const r = canvas.getBoundingClientRect();
|
|
209
|
+
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
|
210
|
+
w = r.width;
|
|
211
|
+
h = r.height;
|
|
212
|
+
canvas.width = Math.round(w * dpr);
|
|
213
|
+
canvas.height = Math.round(h * dpr);
|
|
214
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
215
|
+
};
|
|
216
|
+
const ro = new ResizeObserver(resize);
|
|
217
|
+
ro.observe(canvas);
|
|
218
|
+
resize();
|
|
219
|
+
|
|
220
|
+
jumpRef.current = () => {
|
|
221
|
+
if (g.y === 0) g.vy = 520;
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
const tick = (now) => {
|
|
225
|
+
const dt = Math.min(0.033, (now - last) / 1000);
|
|
226
|
+
last = now;
|
|
227
|
+
const ground = h - 18;
|
|
228
|
+
const px = 42;
|
|
229
|
+
const size = 18;
|
|
230
|
+
|
|
231
|
+
// Physics.
|
|
232
|
+
g.vy -= 1500 * dt;
|
|
233
|
+
g.y = Math.max(0, g.y + g.vy * dt);
|
|
234
|
+
if (g.y === 0) g.vy = Math.max(0, g.vy);
|
|
235
|
+
g.speed = Math.min(420, g.speed + 6 * dt);
|
|
236
|
+
g.spawn -= dt;
|
|
237
|
+
if (g.spawn <= 0) {
|
|
238
|
+
const tall = Math.random() < 0.35;
|
|
239
|
+
g.obstacles.push({ x: w + 10, w: tall ? 14 : 18, h: tall ? 30 : 18 });
|
|
240
|
+
g.spawn = 0.75 + Math.random() * 0.9;
|
|
241
|
+
}
|
|
242
|
+
g.obstacles.forEach((o) => (o.x -= g.speed * dt));
|
|
243
|
+
g.obstacles = g.obstacles.filter((o) => o.x + o.w > -10);
|
|
244
|
+
|
|
245
|
+
// Collision resets the run.
|
|
246
|
+
const top = ground - g.y - size;
|
|
247
|
+
const hit = g.obstacles.some((o) => px + size > o.x + 2 && px < o.x + o.w - 2 && top + size > ground - o.h + 2);
|
|
248
|
+
if (hit) {
|
|
249
|
+
g.obstacles = [];
|
|
250
|
+
g.speed = 220;
|
|
251
|
+
g.flash = 0.25;
|
|
252
|
+
g.score = 0;
|
|
253
|
+
}
|
|
254
|
+
g.score += dt * 10;
|
|
255
|
+
g.flash = Math.max(0, g.flash - dt);
|
|
256
|
+
const s = Math.floor(g.score);
|
|
257
|
+
setScore((prev) => (prev === s ? prev : s));
|
|
258
|
+
setBest((b) => Math.max(b, s));
|
|
259
|
+
|
|
260
|
+
// Draw.
|
|
261
|
+
ctx.clearRect(0, 0, w, h);
|
|
262
|
+
ctx.fillStyle = track;
|
|
263
|
+
ctx.fillRect(0, ground, w, 2);
|
|
264
|
+
for (let x = (now / 12) % 24; x < w; x += 24) ctx.fillRect(w - x, ground + 8, 6, 2);
|
|
265
|
+
ctx.fillStyle = g.flash ? "#ef4444" : color;
|
|
266
|
+
ctx.shadowColor = color;
|
|
267
|
+
ctx.shadowBlur = 12;
|
|
268
|
+
ctx.beginPath();
|
|
269
|
+
ctx.roundRect(px, top, size, size, 5);
|
|
270
|
+
ctx.fill();
|
|
271
|
+
ctx.shadowBlur = 0;
|
|
272
|
+
ctx.fillStyle = ink;
|
|
273
|
+
ctx.globalAlpha = 0.85;
|
|
274
|
+
g.obstacles.forEach((o) => {
|
|
275
|
+
ctx.beginPath();
|
|
276
|
+
ctx.roundRect(o.x, ground - o.h, o.w, o.h, 3);
|
|
277
|
+
ctx.fill();
|
|
278
|
+
});
|
|
279
|
+
ctx.globalAlpha = 1;
|
|
280
|
+
raf = requestAnimationFrame(tick);
|
|
281
|
+
};
|
|
282
|
+
raf = requestAnimationFrame(tick);
|
|
283
|
+
|
|
284
|
+
const onKey = (e) => {
|
|
285
|
+
if (e.code === "Space" || e.code === "ArrowUp") {
|
|
286
|
+
if (canvas.closest("[data-loading-screen]")?.matches(":hover") || document.activeElement === canvas) {
|
|
287
|
+
e.preventDefault();
|
|
288
|
+
jumpRef.current();
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
window.addEventListener("keydown", onKey);
|
|
293
|
+
return () => {
|
|
294
|
+
cancelAnimationFrame(raf);
|
|
295
|
+
ro.disconnect();
|
|
296
|
+
window.removeEventListener("keydown", onKey);
|
|
297
|
+
};
|
|
298
|
+
}, [color, track, ink]);
|
|
299
|
+
|
|
300
|
+
return (
|
|
301
|
+
<div className="w-[min(26rem,86vw)]">
|
|
302
|
+
<div className="mb-2 flex items-center justify-between font-mono text-[0.7rem] uppercase tracking-[0.16em] opacity-70">
|
|
303
|
+
<span>Space / tap to jump</span>
|
|
304
|
+
<span>
|
|
305
|
+
{score} · best {best}
|
|
306
|
+
</span>
|
|
307
|
+
</div>
|
|
308
|
+
<canvas
|
|
309
|
+
ref={canvasRef}
|
|
310
|
+
tabIndex={0}
|
|
311
|
+
aria-label="Runner game: press Space or tap to jump"
|
|
312
|
+
onPointerDown={() => jumpRef.current()}
|
|
313
|
+
className="h-32 w-full cursor-pointer touch-manipulation rounded-2xl border outline-none focus-visible:ring-2"
|
|
314
|
+
style={{ borderColor: track }}
|
|
315
|
+
/>
|
|
316
|
+
<div className="mt-4 flex items-center gap-3">
|
|
317
|
+
<Bar value={value} color={color} track={track} />
|
|
318
|
+
<span className="font-mono text-xs tabular-nums opacity-70">{value}%</span>
|
|
319
|
+
</div>
|
|
320
|
+
</div>
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const LoadingScreen = ({
|
|
325
|
+
variant = "orbit",
|
|
326
|
+
progress,
|
|
327
|
+
duration = 6000,
|
|
328
|
+
loop = true,
|
|
329
|
+
accentColor = "#a3e635",
|
|
330
|
+
theme = "dark",
|
|
331
|
+
title = "Loading",
|
|
332
|
+
fullScreen = false,
|
|
333
|
+
onComplete,
|
|
334
|
+
className = "",
|
|
335
|
+
}) => {
|
|
336
|
+
const moving = !useReducedMotion();
|
|
337
|
+
const value = useProgress(progress, duration, loop, onComplete);
|
|
338
|
+
const dark = theme !== "light";
|
|
339
|
+
const track = dark ? "rgba(255,255,255,0.1)" : "rgba(0,0,0,0.08)";
|
|
340
|
+
const ink = dark ? "#e5e5e5" : "#262626";
|
|
341
|
+
const shared = { value, color: accentColor, track, ink, moving };
|
|
342
|
+
|
|
343
|
+
return (
|
|
344
|
+
<div
|
|
345
|
+
data-loading-screen=""
|
|
346
|
+
role="status"
|
|
347
|
+
aria-live="polite"
|
|
348
|
+
aria-label={`${title} ${value}%`}
|
|
349
|
+
className={`flex flex-col items-center justify-center gap-6 p-8 ${fullScreen ? "fixed inset-0 z-[100]" : "relative h-full w-full"} ${dark ? "bg-neutral-950 text-white" : "bg-white text-neutral-900"} ${className}`}
|
|
350
|
+
>
|
|
351
|
+
{variant === "orbit" && <Orbit {...shared} />}
|
|
352
|
+
{variant === "progress" && <ProgressView {...shared} />}
|
|
353
|
+
{variant === "blocks" && <Blocks {...shared} />}
|
|
354
|
+
{variant === "catch" && <CatchGame {...shared} />}
|
|
355
|
+
{variant === "runner" && <RunnerGame {...shared} />}
|
|
356
|
+
{variant !== "progress" && <p className="text-[0.7rem] font-semibold uppercase tracking-[0.3em] opacity-50">{title}</p>}
|
|
357
|
+
</div>
|
|
358
|
+
);
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
export default LoadingScreen;
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import React, { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { motion, useMotionValue, useReducedMotion, useSpring } from "framer-motion";
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
* Magnetic Cursor: a dot + ring cursor for one area of the page. The ring
|
|
6
|
+
* wraps around links and buttons it hovers, grows into a labelled bubble over
|
|
7
|
+
* anything with data-cursor-text, and pulls data-magnetic elements toward the
|
|
8
|
+
* pointer. An optional tail follows it: comet, dots, sparkles or ghost rings.
|
|
9
|
+
* Touch devices keep their normal behaviour.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const TARGETS = "a, button, [role='button'], [data-cursor], [data-cursor-text], [data-magnetic]";
|
|
13
|
+
|
|
14
|
+
const hexToRgb = (hex) => {
|
|
15
|
+
let h = String(hex).replace("#", "");
|
|
16
|
+
if (h.length === 3) h = h.split("").map((c) => c + c).join("");
|
|
17
|
+
const n = parseInt(h, 16) || 0;
|
|
18
|
+
return `${(n >> 16) & 255},${(n >> 8) & 255},${n & 255}`;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/*
|
|
22
|
+
* The tail lives on one canvas. `feed` gets every pointer position; the loop
|
|
23
|
+
* only runs while something is still visible, then sleeps.
|
|
24
|
+
*/
|
|
25
|
+
function useTrail(canvasRef, style, color, enabled) {
|
|
26
|
+
const state = useRef({ points: [], dots: [], parts: [], rings: [], last: null, raf: 0, lastSpawn: 0 });
|
|
27
|
+
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
const canvas = canvasRef.current;
|
|
30
|
+
if (!canvas || !enabled || style === "none") return undefined;
|
|
31
|
+
const ctx = canvas.getContext("2d");
|
|
32
|
+
const s = state.current;
|
|
33
|
+
const rgb = hexToRgb(color);
|
|
34
|
+
const resize = () => {
|
|
35
|
+
const r = canvas.getBoundingClientRect();
|
|
36
|
+
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
|
37
|
+
canvas.width = Math.round(r.width * dpr);
|
|
38
|
+
canvas.height = Math.round(r.height * dpr);
|
|
39
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
40
|
+
};
|
|
41
|
+
const ro = new ResizeObserver(resize);
|
|
42
|
+
ro.observe(canvas);
|
|
43
|
+
resize();
|
|
44
|
+
|
|
45
|
+
const frame = () => {
|
|
46
|
+
const now = performance.now();
|
|
47
|
+
const { width, height } = canvas.getBoundingClientRect();
|
|
48
|
+
ctx.clearRect(0, 0, width, height);
|
|
49
|
+
let alive = false;
|
|
50
|
+
|
|
51
|
+
if (style === "comet") {
|
|
52
|
+
s.points = s.points.filter((p) => now - p.t < 320);
|
|
53
|
+
const n = s.points.length;
|
|
54
|
+
if (n > 1) {
|
|
55
|
+
alive = true;
|
|
56
|
+
ctx.lineCap = "round";
|
|
57
|
+
ctx.shadowColor = `rgba(${rgb},0.8)`;
|
|
58
|
+
ctx.shadowBlur = 12;
|
|
59
|
+
for (let i = 1; i < n; i++) {
|
|
60
|
+
const k = i / n;
|
|
61
|
+
ctx.strokeStyle = `rgba(${rgb},${k * 0.9})`;
|
|
62
|
+
ctx.lineWidth = 1 + k * 7;
|
|
63
|
+
ctx.beginPath();
|
|
64
|
+
ctx.moveTo(s.points[i - 1].x, s.points[i - 1].y);
|
|
65
|
+
ctx.lineTo(s.points[i].x, s.points[i].y);
|
|
66
|
+
ctx.stroke();
|
|
67
|
+
}
|
|
68
|
+
ctx.shadowBlur = 0;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (style === "dots" && s.last) {
|
|
73
|
+
// Each dot eases toward the one ahead of it.
|
|
74
|
+
if (!s.dots.length) s.dots = Array.from({ length: 14 }, () => ({ x: s.last.x, y: s.last.y }));
|
|
75
|
+
let tx = s.last.x;
|
|
76
|
+
let ty = s.last.y;
|
|
77
|
+
s.dots.forEach((d, i) => {
|
|
78
|
+
d.x += (tx - d.x) * 0.38;
|
|
79
|
+
d.y += (ty - d.y) * 0.38;
|
|
80
|
+
tx = d.x;
|
|
81
|
+
ty = d.y;
|
|
82
|
+
const k = 1 - i / s.dots.length;
|
|
83
|
+
ctx.fillStyle = `rgba(${rgb},${k * 0.8})`;
|
|
84
|
+
ctx.beginPath();
|
|
85
|
+
ctx.arc(d.x, d.y, 1.5 + k * 4, 0, Math.PI * 2);
|
|
86
|
+
ctx.fill();
|
|
87
|
+
});
|
|
88
|
+
const tail = s.dots[s.dots.length - 1];
|
|
89
|
+
alive = Math.hypot(tail.x - s.last.x, tail.y - s.last.y) > 0.5;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (style === "sparkles") {
|
|
93
|
+
s.parts = s.parts.filter((p) => now - p.born < p.life);
|
|
94
|
+
s.parts.forEach((p) => {
|
|
95
|
+
const k = 1 - (now - p.born) / p.life;
|
|
96
|
+
p.x += p.vx;
|
|
97
|
+
p.y += p.vy;
|
|
98
|
+
p.vy += 0.05;
|
|
99
|
+
p.vx *= 0.97;
|
|
100
|
+
const size = p.size * k;
|
|
101
|
+
ctx.strokeStyle = p.white ? `rgba(255,255,255,${k})` : `rgba(${rgb},${k})`;
|
|
102
|
+
ctx.lineWidth = 1.4;
|
|
103
|
+
ctx.beginPath();
|
|
104
|
+
ctx.moveTo(p.x - size, p.y);
|
|
105
|
+
ctx.lineTo(p.x + size, p.y);
|
|
106
|
+
ctx.moveTo(p.x, p.y - size);
|
|
107
|
+
ctx.lineTo(p.x, p.y + size);
|
|
108
|
+
ctx.stroke();
|
|
109
|
+
});
|
|
110
|
+
alive = s.parts.length > 0;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (style === "ghost") {
|
|
114
|
+
s.rings = s.rings.filter((r) => now - r.born < 650);
|
|
115
|
+
s.rings.forEach((r) => {
|
|
116
|
+
const k = (now - r.born) / 650;
|
|
117
|
+
ctx.strokeStyle = `rgba(${rgb},${(1 - k) * 0.7})`;
|
|
118
|
+
ctx.lineWidth = 2 * (1 - k) + 0.5;
|
|
119
|
+
ctx.beginPath();
|
|
120
|
+
ctx.arc(r.x, r.y, 6 + k * 22, 0, Math.PI * 2);
|
|
121
|
+
ctx.stroke();
|
|
122
|
+
});
|
|
123
|
+
alive = s.rings.length > 0;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
s.raf = alive ? requestAnimationFrame(frame) : 0;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
s.wake = () => {
|
|
130
|
+
if (!s.raf) s.raf = requestAnimationFrame(frame);
|
|
131
|
+
};
|
|
132
|
+
return () => {
|
|
133
|
+
cancelAnimationFrame(s.raf);
|
|
134
|
+
s.raf = 0;
|
|
135
|
+
s.wake = null;
|
|
136
|
+
ro.disconnect();
|
|
137
|
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
138
|
+
s.points = [];
|
|
139
|
+
s.dots = [];
|
|
140
|
+
s.parts = [];
|
|
141
|
+
s.rings = [];
|
|
142
|
+
};
|
|
143
|
+
}, [canvasRef, style, color, enabled]);
|
|
144
|
+
|
|
145
|
+
// Called on every pointer move with coordinates inside the area.
|
|
146
|
+
return (x, y) => {
|
|
147
|
+
const s = state.current;
|
|
148
|
+
const now = performance.now();
|
|
149
|
+
const prev = s.last;
|
|
150
|
+
s.last = { x, y };
|
|
151
|
+
if (style === "comet") s.points.push({ x, y, t: now });
|
|
152
|
+
if (style === "sparkles" && prev) {
|
|
153
|
+
const speed = Math.hypot(x - prev.x, y - prev.y);
|
|
154
|
+
for (let i = 0; i < Math.min(4, Math.ceil(speed / 8)); i++) {
|
|
155
|
+
s.parts.push({ x, y, vx: (Math.random() - 0.5) * 2.4, vy: (Math.random() - 0.7) * 2.4, size: 2 + Math.random() * 3, life: 500 + Math.random() * 400, born: now, white: Math.random() < 0.3 });
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (style === "ghost" && now - s.lastSpawn > 70) {
|
|
159
|
+
s.lastSpawn = now;
|
|
160
|
+
s.rings.push({ x, y, born: now });
|
|
161
|
+
}
|
|
162
|
+
s.wake?.();
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const MagneticCursor = ({
|
|
167
|
+
children,
|
|
168
|
+
color = "#a3e635",
|
|
169
|
+
ringSize = 2.25,
|
|
170
|
+
padding = 6,
|
|
171
|
+
magnetStrength = 0.35,
|
|
172
|
+
blend = false,
|
|
173
|
+
trail = "none",
|
|
174
|
+
className = "",
|
|
175
|
+
}) => {
|
|
176
|
+
const ref = useRef(null);
|
|
177
|
+
const trailRef = useRef(null);
|
|
178
|
+
const magnet = useRef(null);
|
|
179
|
+
const reduced = useReducedMotion();
|
|
180
|
+
const feedTrail = useTrail(trailRef, trail, color, !reduced);
|
|
181
|
+
const [visible, setVisible] = useState(false);
|
|
182
|
+
const [label, setLabel] = useState("");
|
|
183
|
+
const [pressed, setPressed] = useState(false);
|
|
184
|
+
|
|
185
|
+
const springy = reduced ? { duration: 0 } : { stiffness: 420, damping: 34, mass: 0.6 };
|
|
186
|
+
const dotX = useMotionValue(-100);
|
|
187
|
+
const dotY = useMotionValue(-100);
|
|
188
|
+
const x = useSpring(-100, springy);
|
|
189
|
+
const y = useSpring(-100, springy);
|
|
190
|
+
const w = useSpring(36, springy);
|
|
191
|
+
const h = useSpring(36, springy);
|
|
192
|
+
const radius = useSpring(18, springy);
|
|
193
|
+
|
|
194
|
+
const ringPx = () => ringSize * parseFloat(getComputedStyle(document.documentElement).fontSize || "16");
|
|
195
|
+
|
|
196
|
+
const releaseMagnet = () => {
|
|
197
|
+
if (magnet.current) {
|
|
198
|
+
magnet.current.style.transform = "";
|
|
199
|
+
magnet.current = null;
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const onMove = (e) => {
|
|
204
|
+
if (e.pointerType !== "mouse") return;
|
|
205
|
+
const box = ref.current.getBoundingClientRect();
|
|
206
|
+
const px = e.clientX - box.left;
|
|
207
|
+
const py = e.clientY - box.top;
|
|
208
|
+
dotX.set(px);
|
|
209
|
+
dotY.set(py);
|
|
210
|
+
setVisible(true);
|
|
211
|
+
feedTrail(px, py);
|
|
212
|
+
|
|
213
|
+
const target = e.target.closest?.(TARGETS);
|
|
214
|
+
const inside = target && ref.current.contains(target) && target !== ref.current;
|
|
215
|
+
const text = inside ? target.getAttribute("data-cursor-text") : null;
|
|
216
|
+
|
|
217
|
+
if (inside && target.hasAttribute("data-magnetic")) {
|
|
218
|
+
const r = target.getBoundingClientRect();
|
|
219
|
+
const mx = (e.clientX - (r.left + r.width / 2)) * magnetStrength;
|
|
220
|
+
const my = (e.clientY - (r.top + r.height / 2)) * magnetStrength;
|
|
221
|
+
target.style.transition = "transform 0.2s ease-out";
|
|
222
|
+
target.style.transform = `translate(${mx}px, ${my}px)`;
|
|
223
|
+
if (magnet.current !== target) releaseMagnet();
|
|
224
|
+
magnet.current = target;
|
|
225
|
+
} else {
|
|
226
|
+
releaseMagnet();
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (text) {
|
|
230
|
+
// Big labelled bubble that follows the pointer.
|
|
231
|
+
const size = ringPx() * 2.2;
|
|
232
|
+
setLabel(text);
|
|
233
|
+
x.set(px - size / 2);
|
|
234
|
+
y.set(py - size / 2);
|
|
235
|
+
w.set(size);
|
|
236
|
+
h.set(size);
|
|
237
|
+
radius.set(size / 2);
|
|
238
|
+
} else if (inside) {
|
|
239
|
+
// Wrap the hovered element (it may be shifted by the magnet).
|
|
240
|
+
const r = target.getBoundingClientRect();
|
|
241
|
+
const br = parseFloat(getComputedStyle(target).borderTopLeftRadius) || 8;
|
|
242
|
+
setLabel("");
|
|
243
|
+
x.set(r.left - box.left - padding);
|
|
244
|
+
y.set(r.top - box.top - padding);
|
|
245
|
+
w.set(r.width + padding * 2);
|
|
246
|
+
h.set(r.height + padding * 2);
|
|
247
|
+
radius.set(Math.min(br + padding, (r.height + padding * 2) / 2));
|
|
248
|
+
} else {
|
|
249
|
+
const size = ringPx();
|
|
250
|
+
setLabel("");
|
|
251
|
+
x.set(px - size / 2);
|
|
252
|
+
y.set(py - size / 2);
|
|
253
|
+
w.set(size);
|
|
254
|
+
h.set(size);
|
|
255
|
+
radius.set(size / 2);
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
const onLeave = () => {
|
|
260
|
+
setVisible(false);
|
|
261
|
+
setLabel("");
|
|
262
|
+
releaseMagnet();
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
return (
|
|
266
|
+
<div
|
|
267
|
+
ref={ref}
|
|
268
|
+
onPointerMove={onMove}
|
|
269
|
+
onPointerLeave={onLeave}
|
|
270
|
+
onPointerDown={(e) => e.pointerType === "mouse" && setPressed(true)}
|
|
271
|
+
onPointerUp={() => setPressed(false)}
|
|
272
|
+
className={`relative ${visible ? "cursor-none [&_*]:cursor-none" : ""} ${className}`}
|
|
273
|
+
>
|
|
274
|
+
{children}
|
|
275
|
+
{trail !== "none" && <canvas ref={trailRef} aria-hidden="true" className="pointer-events-none absolute inset-0 z-40 h-full w-full" />}
|
|
276
|
+
<motion.div
|
|
277
|
+
aria-hidden="true"
|
|
278
|
+
className="pointer-events-none absolute left-0 top-0 z-50 flex items-center justify-center border-2 text-[0.72rem] font-semibold uppercase tracking-[0.14em]"
|
|
279
|
+
style={{
|
|
280
|
+
x,
|
|
281
|
+
y,
|
|
282
|
+
width: w,
|
|
283
|
+
height: h,
|
|
284
|
+
borderRadius: radius,
|
|
285
|
+
borderColor: color,
|
|
286
|
+
color: "#0a0a0a",
|
|
287
|
+
background: label ? color : `color-mix(in srgb, ${color} 12%, transparent)`,
|
|
288
|
+
mixBlendMode: blend ? "difference" : "normal",
|
|
289
|
+
}}
|
|
290
|
+
animate={{ opacity: visible ? 1 : 0, scale: pressed ? 0.88 : 1 }}
|
|
291
|
+
transition={{ duration: 0.18 }}
|
|
292
|
+
>
|
|
293
|
+
{label}
|
|
294
|
+
</motion.div>
|
|
295
|
+
<motion.div
|
|
296
|
+
aria-hidden="true"
|
|
297
|
+
className="pointer-events-none absolute -left-1 -top-1 z-50 h-2 w-2 rounded-full"
|
|
298
|
+
style={{ x: dotX, y: dotY, background: color }}
|
|
299
|
+
animate={{ opacity: visible && !label ? 1 : 0 }}
|
|
300
|
+
transition={{ duration: 0.12 }}
|
|
301
|
+
/>
|
|
302
|
+
</div>
|
|
303
|
+
);
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
export default MagneticCursor;
|