apex-ui-kit 2.6.0 → 2.8.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.
Files changed (33) hide show
  1. package/ReadMe.md +32 -10
  2. package/dist/ApexUI-Kit/templates/AccordionMarquee/AccordionMarquee.jsx +340 -161
  3. package/dist/ApexUI-Kit/templates/AnimatedTabs/AnimatedTabs.jsx +1 -1
  4. package/dist/ApexUI-Kit/templates/ArcGallery/ArcGallery.jsx +356 -0
  5. package/dist/ApexUI-Kit/templates/BounceCards/BounceCards.jsx +22 -2
  6. package/dist/ApexUI-Kit/templates/CircularCarousel/CircularCarousel.jsx +381 -0
  7. package/dist/ApexUI-Kit/templates/CommandPalette/CommandPalette.jsx +250 -0
  8. package/dist/ApexUI-Kit/templates/CrossedMarquee/CrossedMarquee.jsx +158 -0
  9. package/dist/ApexUI-Kit/templates/DitherWaves/DitherWaves.jsx +207 -0
  10. package/dist/ApexUI-Kit/templates/EdgeBlur/EdgeBlur.jsx +92 -0
  11. package/dist/ApexUI-Kit/templates/FlipClock/FlipClock.jsx +5 -1
  12. package/dist/ApexUI-Kit/templates/GlassLens/GlassLens.jsx +274 -0
  13. package/dist/ApexUI-Kit/templates/GooeyNav/GooeyNav.jsx +2 -2
  14. package/dist/ApexUI-Kit/templates/HorizonGlow/HorizonGlow.jsx +259 -0
  15. package/dist/ApexUI-Kit/templates/ImageAccordion/ImageAccordion.jsx +116 -0
  16. package/dist/ApexUI-Kit/templates/InfiniteCanvas/InfiniteCanvas.jsx +230 -0
  17. package/dist/ApexUI-Kit/templates/MagneticButton/MagneticButton.jsx +69 -0
  18. package/dist/ApexUI-Kit/templates/Marquee3D/Marquee3D.jsx +68 -0
  19. package/dist/ApexUI-Kit/templates/NavDropDown/NavDropDown.jsx +123 -1
  20. package/dist/ApexUI-Kit/templates/OrbitIcons/OrbitIcons.jsx +169 -0
  21. package/dist/ApexUI-Kit/templates/ParticleText/ParticleText.jsx +328 -0
  22. package/dist/ApexUI-Kit/templates/RadialMenu/RadialMenu.jsx +119 -0
  23. package/dist/ApexUI-Kit/templates/ScrollStack/ScrollStack.jsx +88 -0
  24. package/dist/ApexUI-Kit/templates/SidebarNav/SidebarNav.jsx +22 -1
  25. package/dist/ApexUI-Kit/templates/SunriseBackground/SunriseBackground.jsx +290 -0
  26. package/dist/ApexUI-Kit/templates/TextRing3D/TextRing3D.jsx +18 -3
  27. package/dist/ApexUI-Kit/templates/ThemeSwitcher/ThemeSwitcher.jsx +215 -0
  28. package/dist/ApexUI-Kit/templates/ThemeSwitcher/themes.js +38 -0
  29. package/dist/ApexUI-Kit/templates/ToastStack/ToastStack.jsx +21 -13
  30. package/dist/ApexUI-Kit/templates/WireframeKnot3D/WireframeKnot3D.jsx +112 -0
  31. package/dist/cli/index.js +20 -0
  32. package/package.json +2 -2
  33. package/dist/ApexUI-Kit/templates/AccordionMarquee/AccordionMarquee.css +0 -1
@@ -0,0 +1,169 @@
1
+ import React, { useEffect, useRef, useState } from "react";
2
+ import { Cloud, Code2, Cpu, Database, Figma, Github, Globe, Layers, Lock, Palette, Rocket, Sparkles, Zap } from "lucide-react";
3
+
4
+ /*
5
+ * Orbit Icons: icons circling a center on a few rings, each ring at its own
6
+ * speed and direction. Tip the orbits back for depth: icons on the far side
7
+ * shrink, dim and pass behind the near ones. Hovering eases the motion to a
8
+ * stop (and shows labels); the orbits lean a little toward the pointer. It
9
+ * scales to its container, so it fits phones.
10
+ */
11
+
12
+ const DEFAULT_RINGS = [
13
+ { speed: 22, items: [{ icon: Code2, label: "Code" }, { icon: Zap, label: "Fast" }, { icon: Palette, label: "Design" }] },
14
+ { speed: -14, items: [{ icon: Github, label: "GitHub" }, { icon: Figma, label: "Figma" }, { icon: Sparkles, label: "AI" }, { icon: Cpu, label: "Compute" }] },
15
+ { speed: 9, items: [{ icon: Globe, label: "Web" }, { icon: Database, label: "Data" }, { icon: Cloud, label: "Cloud" }, { icon: Lock, label: "Auth" }, { icon: Rocket, label: "Deploy" }, { icon: Layers, label: "Stack" }] },
16
+ ];
17
+
18
+ const OrbitIcons = ({ rings = DEFAULT_RINGS, size = 28, tilt = 0.55, speed = 1, pauseOnHover = true, showRings = true, accentColor = "#a3e635", center, className = "" }) => {
19
+ const box = useRef(null);
20
+ const nodes = useRef([]);
21
+ const ringEls = useRef([]);
22
+ const [hover, setHover] = useState(false);
23
+ const live = useRef({});
24
+ live.current = { tilt, speed, pauseOnHover, rings };
25
+ const flat = rings.flatMap((r, ri) => r.items.map((it, k) => ({ ...it, ri, k, n: r.items.length })));
26
+
27
+ useEffect(() => {
28
+ const el = box.current;
29
+ const reduced = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
30
+ const s = { angles: live.current.rings.map((_, i) => i * 0.7), rate: 1, half: 100, lean: 0, leanX: 0, tx: 0, ty: 0, hover: false };
31
+ let raf = 0;
32
+ let last = 0;
33
+ let visible = true;
34
+
35
+ const fit = () => {
36
+ s.half = el.getBoundingClientRect().width / 2;
37
+ el.style.setProperty("--orbit-item", `${Math.max(30, Math.min(52, s.half * 0.21)).toFixed(1)}px`);
38
+ };
39
+ const frame = (now) => {
40
+ raf = 0;
41
+ const o = live.current;
42
+ const dt = last ? Math.min(0.05, (now - last) / 1000) : 1 / 60;
43
+ last = now;
44
+ // Ease the spin down on hover rather than stopping dead.
45
+ const goal = (o.pauseOnHover && s.hover) || reduced ? 0 : 1;
46
+ s.rate += (goal - s.rate) * (1 - Math.exp(-dt / 0.35));
47
+ s.lean += (s.ty - s.lean) * (1 - Math.exp(-dt / 0.4));
48
+ s.leanX += (s.tx - s.leanX) * (1 - Math.exp(-dt / 0.4));
49
+ const tiltA = Math.max(0, Math.min(1.2, o.tilt * 1.15 + s.lean * 0.18));
50
+ const squash = Math.cos(tiltA);
51
+ const depthAmt = Math.sin(tiltA);
52
+ o.rings.forEach((ring, ri) => {
53
+ s.angles[ri] = (s.angles[ri] ?? 0) + ((ring.speed * Math.PI) / 180) * o.speed * s.rate * dt;
54
+ });
55
+ const radii = o.rings.map((_, ri) => s.half * (0.34 + (0.6 * (ri + 1)) / o.rings.length) * 0.94);
56
+ ringEls.current.forEach((e, ri) => {
57
+ if (!e) return;
58
+ const r = radii[ri];
59
+ e.style.width = `${r * 2}px`;
60
+ e.style.height = `${r * 2 * squash}px`;
61
+ e.style.transform = `translate(-50%, -50%) rotate(${(s.leanX * 6).toFixed(2)}deg)`;
62
+ });
63
+ nodes.current.forEach((e, idx) => {
64
+ const it = flat[idx];
65
+ if (!e || !it) return;
66
+ const r = radii[it.ri];
67
+ const a = s.angles[it.ri] + (it.k / it.n) * Math.PI * 2;
68
+ const z = Math.sin(a); // +1 is the near side (bottom)
69
+ const lean = (s.leanX * 6 * Math.PI) / 180;
70
+ let x = Math.cos(a) * r;
71
+ let y = Math.sin(a) * r * squash;
72
+ [x, y] = [x * Math.cos(lean) - y * Math.sin(lean), x * Math.sin(lean) + y * Math.cos(lean)];
73
+ const sc = 1 + z * 0.16 * depthAmt;
74
+ e.style.transform = `translate(-50%, -50%) translate3d(${x.toFixed(2)}px, ${y.toFixed(2)}px, 0) scale(${sc.toFixed(3)})`;
75
+ e.style.zIndex = String(Math.round(50 + z * 40));
76
+ e.style.opacity = String((1 - (1 - z) * 0.22 * depthAmt).toFixed(3));
77
+ });
78
+ const busy = s.rate > 0.001 || Math.abs(s.ty - s.lean) > 0.001 || Math.abs(s.tx - s.leanX) > 0.001;
79
+ if (visible && busy) raf = requestAnimationFrame(frame);
80
+ else last = 0;
81
+ };
82
+ const start = () => {
83
+ if (!raf && visible) raf = requestAnimationFrame(frame);
84
+ };
85
+ const onMove = (e) => {
86
+ const r = el.getBoundingClientRect();
87
+ s.tx = ((e.clientX - r.left) / r.width) * 2 - 1;
88
+ s.ty = ((e.clientY - r.top) / r.height) * 2 - 1;
89
+ start();
90
+ };
91
+ const onEnter = (e) => {
92
+ if (e.pointerType === "mouse") {
93
+ s.hover = true;
94
+ setHover(true);
95
+ }
96
+ start();
97
+ };
98
+ const onLeave = () => {
99
+ s.hover = false;
100
+ s.tx = 0;
101
+ s.ty = 0;
102
+ setHover(false);
103
+ start();
104
+ };
105
+ el.addEventListener("pointermove", onMove);
106
+ el.addEventListener("pointerenter", onEnter);
107
+ el.addEventListener("pointerleave", onLeave);
108
+ const ro = new ResizeObserver(() => {
109
+ fit();
110
+ start();
111
+ });
112
+ ro.observe(el);
113
+ const io = new IntersectionObserver(([en]) => {
114
+ visible = en.isIntersecting;
115
+ if (visible) start();
116
+ });
117
+ io.observe(el);
118
+ fit();
119
+ start();
120
+ return () => {
121
+ cancelAnimationFrame(raf);
122
+ ro.disconnect();
123
+ io.disconnect();
124
+ el.removeEventListener("pointermove", onMove);
125
+ el.removeEventListener("pointerenter", onEnter);
126
+ el.removeEventListener("pointerleave", onLeave);
127
+ };
128
+ // flat is derived from rings; the loop reads both through refs and live.
129
+ // eslint-disable-next-line react-hooks/exhaustive-deps
130
+ }, [flat.length]);
131
+
132
+ return (
133
+ <div ref={box} className={`relative aspect-square w-full select-none ${className}`} style={{ maxWidth: `${size}rem`, "--orbit-accent": accentColor }}>
134
+ {showRings &&
135
+ rings.map((_, ri) => (
136
+ <span
137
+ key={ri}
138
+ ref={(e) => (ringEls.current[ri] = e)}
139
+ aria-hidden="true"
140
+ className="pointer-events-none absolute left-1/2 top-1/2 rounded-[50%] border border-dashed border-current opacity-15"
141
+ />
142
+ ))}
143
+ <div className="absolute left-1/2 top-1/2 z-[50] grid aspect-square w-[22%] -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full border border-white/10 bg-neutral-950 text-white shadow-[0_0_60px_-10px_var(--orbit-accent)]">
144
+ {center ?? <span className="text-base font-black tracking-tight sm:text-xl" style={{ color: accentColor }}>Apex</span>}
145
+ </div>
146
+ {flat.map((it, idx) => {
147
+ const Icon = it.icon;
148
+ return (
149
+ <div key={idx} ref={(e) => (nodes.current[idx] = e)} className="group absolute left-1/2 top-1/2 will-change-transform">
150
+ <div
151
+ className="grid place-items-center rounded-2xl border border-white/10 bg-neutral-900/90 text-white shadow-[0_10px_30px_-12px_rgba(0,0,0,0.7)] backdrop-blur transition-colors duration-300 group-hover:border-[var(--orbit-accent)]"
152
+ style={{ width: "var(--orbit-item, 2.75rem)", aspectRatio: "1" }}
153
+ title={it.label}
154
+ >
155
+ {typeof Icon === "function" || (Icon && typeof Icon === "object" && Icon.$$typeof) ? <Icon className="h-[46%] w-[46%]" style={{ color: it.color }} /> : Icon}
156
+ </div>
157
+ {it.label && (
158
+ <span className="pointer-events-none absolute left-1/2 top-full mt-1.5 -translate-x-1/2 whitespace-nowrap rounded-full bg-black/70 px-2 py-0.5 text-[0.65rem] font-semibold text-white transition-opacity duration-300" style={{ opacity: hover ? 1 : 0 }}>
159
+ {it.label}
160
+ </span>
161
+ )}
162
+ </div>
163
+ );
164
+ })}
165
+ </div>
166
+ );
167
+ };
168
+
169
+ export default OrbitIcons;
@@ -0,0 +1,328 @@
1
+ import React, { useEffect, useRef } from "react";
2
+
3
+ /*
4
+ * Particle Text: your text rebuilt from thousands of tiny particles on a
5
+ * canvas. They fly in to form the words (assemble, fall, explode or wave),
6
+ * move out of the pointer's way, and a click sends a shockwave through them
7
+ * before they spring back. Moving particles light up in the accent color.
8
+ * When everything has settled and the pointer is away, the loop sleeps.
9
+ */
10
+
11
+ const TAU = Math.PI * 2;
12
+
13
+ // Points where the text is drawn, sampled from an offscreen canvas.
14
+ function sampleText(text, width, height, weight, family, maxSize, gap) {
15
+ const off = document.createElement("canvas");
16
+ off.width = Math.max(1, Math.floor(width));
17
+ off.height = Math.max(1, Math.floor(height));
18
+ const c = off.getContext("2d", { willReadFrequently: true });
19
+ let size = Math.min(maxSize, height * 0.7);
20
+ c.font = `${weight} ${size}px ${family}`;
21
+ const measured = c.measureText(text).width;
22
+ // Shrink to fit 90% of the width.
23
+ if (measured > width * 0.9) size *= (width * 0.9) / measured;
24
+ c.font = `${weight} ${size}px ${family}`;
25
+ c.textAlign = "center";
26
+ c.textBaseline = "middle";
27
+ c.fillStyle = "#fff";
28
+ c.fillText(text, width / 2, height / 2);
29
+ const data = c.getImageData(0, 0, off.width, off.height).data;
30
+ const pts = [];
31
+ for (let y = 0; y < off.height; y += gap) {
32
+ for (let x = 0; x < off.width; x += gap) {
33
+ if (data[(y * off.width + x) * 4 + 3] > 120) pts.push(x, y);
34
+ }
35
+ }
36
+ return pts;
37
+ }
38
+
39
+ const ParticleText = ({
40
+ text = "ApexUI",
41
+ fontSize = 9,
42
+ fontWeight = 800,
43
+ fontFamily,
44
+ particleSize = 2,
45
+ gap = 4,
46
+ color = "currentColor",
47
+ accentColor = "#a3e635",
48
+ intro = "assemble",
49
+ replayOn = "none",
50
+ repelRadius = 90,
51
+ repelStrength = 1,
52
+ clickBurst = true,
53
+ height = "20rem",
54
+ className = "",
55
+ }) => {
56
+ const wrap = useRef(null);
57
+ const canvas = useRef(null);
58
+ const live = useRef({ particleSize, accentColor, color, repelRadius, repelStrength, clickBurst, replayOn, intro });
59
+ live.current = { particleSize, accentColor, color, repelRadius, repelStrength, clickBurst, replayOn, intro };
60
+ const wakeRef = useRef(() => {});
61
+
62
+ // Prop changes that don't need a rebuild just wake the loop to redraw.
63
+ useEffect(() => {
64
+ wakeRef.current();
65
+ });
66
+
67
+ useEffect(() => {
68
+ const el = wrap.current;
69
+ const cv = canvas.current;
70
+ const ctx = cv.getContext("2d");
71
+ const reduced = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
72
+ let P = new Float32Array(0); // per particle: x, y, vx, vy, tx, ty, delay, seed
73
+ let n = 0;
74
+ let w = 0;
75
+ let h = 0;
76
+ let dpr = 1;
77
+ let raf = 0;
78
+ let last = 0;
79
+ let started = 0;
80
+ let alive = true;
81
+ let visible = true;
82
+ let built = "";
83
+ const pointer = { x: -9999, y: -9999, on: false };
84
+
85
+ const startPositions = (mode) => {
86
+ started = performance.now();
87
+ for (let i = 0; i < n; i++) {
88
+ const o = i * 8;
89
+ const tx = P[o + 4];
90
+ const ty = P[o + 5];
91
+ const seed = P[o + 7];
92
+ let x = tx;
93
+ let y = ty;
94
+ let delay = 0;
95
+ if (reduced) {
96
+ // Appear in place.
97
+ } else if (mode === "fall") {
98
+ x = tx + (seed - 0.5) * 30;
99
+ y = -20 - seed * h * 0.6;
100
+ delay = seed * 500;
101
+ } else if (mode === "explode") {
102
+ const a = seed * TAU * 7;
103
+ x = w / 2 + Math.cos(a) * 4;
104
+ y = h / 2 + Math.sin(a) * 4;
105
+ delay = seed * 200;
106
+ } else if (mode === "wave") {
107
+ x = tx - w * 0.6;
108
+ y = ty + Math.sin(tx * 0.04) * 40;
109
+ delay = (tx / w) * 700;
110
+ } else {
111
+ x = Math.random() * w;
112
+ y = Math.random() * h;
113
+ delay = seed * 350;
114
+ }
115
+ P[o] = x;
116
+ P[o + 1] = y;
117
+ P[o + 2] = mode === "explode" && !reduced ? (Math.random() - 0.5) * 900 : 0;
118
+ P[o + 3] = mode === "explode" && !reduced ? (Math.random() - 0.5) * 900 : 0;
119
+ P[o + 6] = delay;
120
+ }
121
+ wake();
122
+ };
123
+
124
+ const build = (withIntro) => {
125
+ const r = el.getBoundingClientRect();
126
+ if (!r.width || !r.height) return;
127
+ dpr = Math.min(window.devicePixelRatio || 1, 2);
128
+ w = r.width;
129
+ h = r.height;
130
+ cv.width = Math.round(w * dpr);
131
+ cv.height = Math.round(h * dpr);
132
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
133
+ const rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
134
+ const family = fontFamily || getComputedStyle(el).fontFamily || "sans-serif";
135
+ const pts = sampleText(String(text || " "), w, h, fontWeight, family, fontSize * rem, Math.max(2, Math.round(gap)));
136
+ // Cap the count so huge containers stay smooth.
137
+ const cap = 6000;
138
+ const stride = Math.max(1, Math.ceil(pts.length / 2 / cap));
139
+ n = Math.floor(pts.length / 2 / stride);
140
+ const old = P;
141
+ const oldN = old.length / 8;
142
+ P = new Float32Array(n * 8);
143
+ for (let i = 0; i < n; i++) {
144
+ const o = i * 8;
145
+ const s = i * stride * 2;
146
+ P[o + 4] = pts[s];
147
+ P[o + 5] = pts[s + 1];
148
+ P[o + 7] = ((i * 9301 + 49297) % 233280) / 233280;
149
+ // Keep current positions on resize so nothing jumps.
150
+ if (!withIntro && i < oldN) {
151
+ P[o] = old[i * 8];
152
+ P[o + 1] = old[i * 8 + 1];
153
+ } else {
154
+ P[o] = pts[s];
155
+ P[o + 1] = pts[s + 1];
156
+ }
157
+ }
158
+ if (withIntro) startPositions(live.current.intro);
159
+ wake();
160
+ };
161
+
162
+ const toRgb = (() => {
163
+ const c = document.createElement("canvas").getContext("2d");
164
+ return (value) => {
165
+ c.fillStyle = "#000";
166
+ c.fillStyle = value;
167
+ const hex = c.fillStyle;
168
+ if (hex.startsWith("#")) return [parseInt(hex.slice(1, 3), 16), parseInt(hex.slice(3, 5), 16), parseInt(hex.slice(5, 7), 16)];
169
+ const m = hex.match(/\d+(\.\d+)?/g) || [0, 0, 0];
170
+ return [+m[0], +m[1], +m[2]];
171
+ };
172
+ })();
173
+
174
+ const frame = (now) => {
175
+ raf = 0;
176
+ if (!alive) return;
177
+ const o = live.current;
178
+ const dt = last ? Math.min(0.033, (now - last) / 1000) : 1 / 60;
179
+ last = now;
180
+ const elapsed = now - started;
181
+ const base = o.color === "currentColor" ? getComputedStyle(el).color : o.color;
182
+ const [br, bg, bb] = toRgb(base);
183
+ const [ar, ag, ab] = toRgb(o.accentColor);
184
+ const size = Math.max(0.5, o.particleSize);
185
+ const rr = o.repelRadius;
186
+ const rs = o.repelStrength * 2600;
187
+ let busy = pointer.on;
188
+
189
+ ctx.clearRect(0, 0, w, h);
190
+ // Two passes: settled particles in the base color, moving ones in the accent.
191
+ const hot = [];
192
+ ctx.fillStyle = `rgb(${br}, ${bg}, ${bb})`;
193
+ ctx.beginPath();
194
+ for (let i = 0; i < n; i++) {
195
+ const k = i * 8;
196
+ let x = P[k];
197
+ let y = P[k + 1];
198
+ let vx = P[k + 2];
199
+ let vy = P[k + 3];
200
+ if (elapsed >= P[k + 6]) {
201
+ // Spring toward the target, with damping.
202
+ vx += (P[k + 4] - x) * 60 * dt;
203
+ vy += (P[k + 5] - y) * 60 * dt;
204
+ if (pointer.on && rr > 0) {
205
+ const dx = x - pointer.x;
206
+ const dy = y - pointer.y;
207
+ const d2 = dx * dx + dy * dy;
208
+ if (d2 < rr * rr && d2 > 0.01) {
209
+ const d = Math.sqrt(d2);
210
+ const f = (1 - d / rr) * rs * dt;
211
+ vx += (dx / d) * f;
212
+ vy += (dy / d) * f;
213
+ }
214
+ }
215
+ const damp = Math.pow(0.0009, dt);
216
+ vx *= damp;
217
+ vy *= damp;
218
+ x += vx * dt;
219
+ y += vy * dt;
220
+ }
221
+ P[k] = x;
222
+ P[k + 1] = y;
223
+ P[k + 2] = vx;
224
+ P[k + 3] = vy;
225
+ const off = Math.abs(P[k + 4] - x) + Math.abs(P[k + 5] - y);
226
+ if (off > 0.3 || Math.abs(vx) + Math.abs(vy) > 2) busy = true;
227
+ if (off > 4) hot.push(x, y, Math.min(1, off / 40));
228
+ else ctx.rect(x - size / 2, y - size / 2, size, size);
229
+ }
230
+ ctx.fill();
231
+ if (hot.length) {
232
+ ctx.globalCompositeOperation = "lighter";
233
+ for (let j = 0; j < hot.length; j += 3) {
234
+ const t = hot[j + 2];
235
+ ctx.fillStyle = `rgb(${Math.round(br + (ar - br) * t)}, ${Math.round(bg + (ag - bg) * t)}, ${Math.round(bb + (ab - bb) * t)})`;
236
+ ctx.fillRect(hot[j] - size / 2, hot[j + 1] - size / 2, size, size);
237
+ }
238
+ ctx.globalCompositeOperation = "source-over";
239
+ }
240
+ if (busy && visible) raf = requestAnimationFrame(frame);
241
+ };
242
+
243
+ function wake() {
244
+ if (raf || !alive || !visible) return;
245
+ last = 0;
246
+ raf = requestAnimationFrame(frame);
247
+ }
248
+ wakeRef.current = wake;
249
+
250
+ const local = (e) => {
251
+ const r = cv.getBoundingClientRect();
252
+ return [e.clientX - r.left, e.clientY - r.top];
253
+ };
254
+ const onMove = (e) => {
255
+ [pointer.x, pointer.y] = local(e);
256
+ pointer.on = true;
257
+ wake();
258
+ };
259
+ const onEnter = (e) => {
260
+ onMove(e);
261
+ if (live.current.replayOn === "hover") startPositions(live.current.intro);
262
+ };
263
+ const onLeave = () => {
264
+ pointer.on = false;
265
+ pointer.x = pointer.y = -9999;
266
+ wake();
267
+ };
268
+ const onDown = (e) => {
269
+ const o = live.current;
270
+ if (o.replayOn === "click") return startPositions(o.intro);
271
+ if (!o.clickBurst || reduced) return;
272
+ const [cx, cy] = local(e);
273
+ for (let i = 0; i < n; i++) {
274
+ const k = i * 8;
275
+ const dx = P[k] - cx;
276
+ const dy = P[k + 1] - cy;
277
+ const d = Math.hypot(dx, dy) || 1;
278
+ const f = Math.max(0, 1 - d / 260) * 1400 * (0.6 + P[k + 7] * 0.8);
279
+ P[k + 2] += (dx / d) * f;
280
+ P[k + 3] += (dy / d) * f;
281
+ }
282
+ wake();
283
+ };
284
+ cv.addEventListener("pointerenter", onEnter);
285
+ cv.addEventListener("pointermove", onMove);
286
+ cv.addEventListener("pointerleave", onLeave);
287
+ cv.addEventListener("pointerdown", onDown);
288
+
289
+ const ro = new ResizeObserver(() => {
290
+ const r = el.getBoundingClientRect();
291
+ const key = `${Math.round(r.width)}x${Math.round(r.height)}`;
292
+ if (key === built) return;
293
+ const first = !built;
294
+ built = key;
295
+ build(first);
296
+ });
297
+ ro.observe(el);
298
+ const io = new IntersectionObserver(([entry]) => {
299
+ visible = entry.isIntersecting;
300
+ wake();
301
+ });
302
+ io.observe(el);
303
+ // Rebuild once web fonts are ready so the shape uses the real font.
304
+ document.fonts?.ready.then(() => alive && built && build(false));
305
+
306
+ return () => {
307
+ alive = false;
308
+ wakeRef.current = () => {};
309
+ if (raf) cancelAnimationFrame(raf);
310
+ ro.disconnect();
311
+ io.disconnect();
312
+ cv.removeEventListener("pointerenter", onEnter);
313
+ cv.removeEventListener("pointermove", onMove);
314
+ cv.removeEventListener("pointerleave", onLeave);
315
+ cv.removeEventListener("pointerdown", onDown);
316
+ };
317
+ // Text and font settings rebuild the particles; the rest is read live.
318
+ }, [text, fontSize, fontWeight, fontFamily, gap]);
319
+
320
+ return (
321
+ <div ref={wrap} className={`relative w-full touch-pan-y select-none overflow-hidden ${className}`} style={{ height }}>
322
+ <canvas ref={canvas} aria-hidden="true" className="absolute inset-0 block h-full w-full" />
323
+ <span className="sr-only">{text}</span>
324
+ </div>
325
+ );
326
+ };
327
+
328
+ export default ParticleText;
@@ -0,0 +1,119 @@
1
+ import React, { useEffect, useId, useRef, useState } from "react";
2
+ import { AnimatePresence, motion } from "framer-motion";
3
+ import { Camera, Heart, Home, MessageCircle, Plus, Settings, Share2, User } from "lucide-react";
4
+
5
+ /*
6
+ * Radial Menu: a round button that fans its actions out in a circle (or an
7
+ * arc). Items spring out one after another and tuck back in on close.
8
+ * Keyboard friendly: arrows move between items, Escape closes, and clicking
9
+ * outside closes too. The ring shrinks to fit narrow containers, and the
10
+ * fade-in is a CSS animation so no item blinks when its spring settles.
11
+ */
12
+
13
+ const DEFAULT_ITEMS = [
14
+ { icon: Home, label: "Home" },
15
+ { icon: User, label: "Profile" },
16
+ { icon: MessageCircle, label: "Messages" },
17
+ { icon: Camera, label: "Camera" },
18
+ { icon: Heart, label: "Likes" },
19
+ { icon: Share2, label: "Share" },
20
+ { icon: Settings, label: "Settings" },
21
+ ];
22
+
23
+ const RadialMenu = ({ items = DEFAULT_ITEMS, radius = 6.5, arc = 360, startAngle = -90, accentColor = "#a3e635", theme = "dark", onSelect, className = "" }) => {
24
+ const id = useId();
25
+ const [open, setOpen] = useState(false);
26
+ const [picked, setPicked] = useState(null);
27
+ const root = useRef(null);
28
+ const btns = useRef([]);
29
+ const [fitR, setFitR] = useState(null);
30
+ const dark = theme !== "light";
31
+ const n = items.length;
32
+ // Full circle spreads evenly; a partial arc includes both ends.
33
+ const step = arc >= 360 ? 360 / n : arc / Math.max(1, n - 1);
34
+
35
+ // Keep the ring inside the box on small screens (item half size + a little room).
36
+ useEffect(() => {
37
+ const el = root.current;
38
+ const ro = new ResizeObserver(([entry]) => {
39
+ const rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
40
+ const room = entry.contentRect.width / 2 - 30;
41
+ setFitR(Math.max(48, Math.min(radius * rem, room)));
42
+ });
43
+ ro.observe(el);
44
+ return () => ro.disconnect();
45
+ }, [radius]);
46
+
47
+ useEffect(() => {
48
+ if (!open) return undefined;
49
+ const down = (e) => !root.current?.contains(e.target) && setOpen(false);
50
+ const key = (e) => e.key === "Escape" && setOpen(false);
51
+ document.addEventListener("pointerdown", down);
52
+ document.addEventListener("keydown", key);
53
+ return () => {
54
+ document.removeEventListener("pointerdown", down);
55
+ document.removeEventListener("keydown", key);
56
+ };
57
+ }, [open]);
58
+
59
+ const choose = (item, i) => {
60
+ setPicked(i);
61
+ setOpen(false);
62
+ onSelect?.(item, i);
63
+ };
64
+ const onItemKey = (e, i) => {
65
+ const next = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }[e.key];
66
+ if (!next) return;
67
+ e.preventDefault();
68
+ btns.current[(i + next + n) % n]?.focus();
69
+ };
70
+
71
+ return (
72
+ <div ref={root} className={`relative inline-grid place-items-center ${className}`} style={{ width: `${radius * 2 + 4}rem`, height: `${radius * 2 + 4}rem`, maxWidth: "100%" }}>
73
+ <style>{"@keyframes apex-radial-in{from{opacity:0}to{opacity:1}}"}</style>
74
+ <AnimatePresence>
75
+ {open &&
76
+ items.map((item, i) => {
77
+ const a = ((startAngle + i * step) * Math.PI) / 180;
78
+ const Icon = item.icon;
79
+ const r = fitR ?? radius * 16;
80
+ return (
81
+ <motion.button
82
+ key={`${id}-${i}`}
83
+ ref={(el) => (btns.current[i] = el)}
84
+ type="button"
85
+ aria-label={item.label}
86
+ title={item.label}
87
+ onClick={() => choose(item, i)}
88
+ onKeyDown={(e) => onItemKey(e, i)}
89
+ className={`group absolute grid h-12 w-12 place-items-center rounded-full shadow-lg outline-none ring-1 focus-visible:ring-2 ${dark ? "bg-neutral-900 text-white ring-white/10 hover:bg-neutral-800 focus-visible:ring-white/50" : "bg-white text-neutral-800 ring-black/10 hover:bg-neutral-50 focus-visible:ring-black/40"}`}
90
+ initial={{ x: 0, y: 0, scale: 0.3 }}
91
+ animate={{ x: Math.cos(a) * r, y: Math.sin(a) * r, scale: 1, transition: { type: "spring", stiffness: 380, damping: 26, delay: i * 0.03 } }}
92
+ exit={{ x: 0, y: 0, scale: 0.3, opacity: 0, transition: { duration: 0.18, delay: (n - 1 - i) * 0.015 } }}
93
+ style={{ animation: `apex-radial-in 220ms ease-out ${i * 30}ms backwards`, ...(picked === i ? { color: accentColor } : null) }}
94
+ >
95
+ {Icon ? <Icon className="h-5 w-5" /> : item.label[0]}
96
+ <span className={`pointer-events-none absolute top-full mt-1.5 whitespace-nowrap rounded-md px-1.5 py-0.5 text-[0.65rem] font-medium opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100 ${dark ? "bg-white text-black" : "bg-neutral-900 text-white"}`}>{item.label}</span>
97
+ </motion.button>
98
+ );
99
+ })}
100
+ </AnimatePresence>
101
+ <motion.button
102
+ type="button"
103
+ aria-expanded={open}
104
+ aria-label={open ? "Close menu" : "Open menu"}
105
+ onClick={() => setOpen((o) => !o)}
106
+ onKeyDown={(e) => open && e.key === "ArrowRight" && (e.preventDefault(), btns.current[0]?.focus())}
107
+ whileTap={{ scale: 0.92 }}
108
+ className="relative z-10 grid h-16 w-16 place-items-center rounded-full text-black shadow-[0_10px_30px_-8px_rgba(0,0,0,0.5)] outline-none focus-visible:ring-4 focus-visible:ring-white/40"
109
+ style={{ background: accentColor }}
110
+ >
111
+ <motion.span animate={{ rotate: open ? 135 : 0 }} transition={{ type: "spring", stiffness: 400, damping: 22 }}>
112
+ <Plus className="h-7 w-7" strokeWidth={2.5} />
113
+ </motion.span>
114
+ </motion.button>
115
+ </div>
116
+ );
117
+ };
118
+
119
+ export default RadialMenu;