apex-ui-kit 2.7.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.
@@ -0,0 +1,230 @@
1
+ import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
2
+
3
+ /*
4
+ * Infinite Canvas: an endless wall of image cards you can drag, flick or
5
+ * scroll in any direction. It glides to a stop with momentum, cards lean
6
+ * with the speed of the move and settle flat again, and the wall drifts on
7
+ * its own while nobody touches it. Only enough cards to cover the view are
8
+ * rendered; they are recycled as they leave an edge, so it stays light.
9
+ */
10
+
11
+ const IK = "https://ik.imagekit.io/ny6yinyut/apexui/assets/componentsimage";
12
+ const shot = (n, t) => ({ image: `${IK}/${n}.png?tr=f-auto,q-75,w-520`, title: t });
13
+ const DEFAULT_ITEMS = [
14
+ shot("galaxy-3d", "Galaxy 3D"),
15
+ shot("warp-tunnel", "Warp Tunnel"),
16
+ shot("liquid-blob-3d", "Liquid Blob 3D"),
17
+ shot("particle-text", "Particle Text"),
18
+ shot("retro-grid-3d", "Retro Grid 3D"),
19
+ shot("marquee-3d", "Marquee 3D"),
20
+ shot("sunrise-background", "Sunrise Background"),
21
+ shot("circular-carousel", "Circular Carousel"),
22
+ shot("wireframe-knot-3d", "Wireframe Knot 3D"),
23
+ shot("arc-gallery", "Arc Gallery"),
24
+ shot("particle-morph", "Particle Morph"),
25
+ shot("scroll-stack", "Scroll Stack"),
26
+ ];
27
+
28
+ const InfiniteCanvas = ({ items = DEFAULT_ITEMS, cardWidth = 14, aspectRatio = 0.8, gap = 1.25, drift = 18, tilt = 1, friction = 0.93, showTitles = true, height = "34rem", onItemClick, className = "" }) => {
29
+ const wrap = useRef(null);
30
+ const tiles = useRef([]);
31
+ const [pool, setPool] = useState(0);
32
+ const live = useRef({});
33
+ live.current = { drift, tilt, friction, showTitles, onItemClick };
34
+ const list = items.length ? items : DEFAULT_ITEMS;
35
+ const listRef = useRef(list);
36
+ listRef.current = list;
37
+ const geo = useRef({ cw: 1, ch: 1, cols: 0, rows: 0 });
38
+
39
+ // Size the pool to cover the view (plus a ring of spares) whenever it resizes.
40
+ useLayoutEffect(() => {
41
+ const el = wrap.current;
42
+ const fit = () => {
43
+ const { width, height: hh } = el.getBoundingClientRect();
44
+ const rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
45
+ // Cards shrink a little on small screens so a few always fit.
46
+ const w = Math.min(cardWidth * rem, Math.max(width * 0.42, 120));
47
+ const cw = w + gap * rem;
48
+ const ch = w / aspectRatio + gap * rem;
49
+ const cols = Math.ceil(width / cw) + 2;
50
+ const rows = Math.ceil(hh / ch) + 3;
51
+ geo.current = { cw, ch, w, h: w / aspectRatio, cols, rows };
52
+ setPool(cols * rows);
53
+ };
54
+ fit();
55
+ const ro = new ResizeObserver(fit);
56
+ ro.observe(el);
57
+ return () => ro.disconnect();
58
+ }, [cardWidth, aspectRatio, gap]);
59
+
60
+ useEffect(() => {
61
+ const el = wrap.current;
62
+ if (!pool) return undefined;
63
+ const reduced = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
64
+ const s = { ox: 0, oy: 0, vx: 0, vy: 0, lean: { x: 0, y: 0 }, drag: null, hover: false };
65
+ const slots = tiles.current.slice(0, pool).map(() => ({ key: "" }));
66
+ let raf = 0;
67
+ let last = 0;
68
+ let visible = true;
69
+
70
+ const paint = () => {
71
+ const { cw, ch, cols } = geo.current;
72
+ const items = listRef.current;
73
+ const n = items.length;
74
+ const c0 = Math.floor(-s.ox / cw) - 1;
75
+ const r0 = Math.floor(-s.oy / ch) - 2;
76
+ const rx = Math.max(-10, Math.min(10, -s.lean.y * 0.012 * live.current.tilt));
77
+ const ry = Math.max(-10, Math.min(10, s.lean.x * 0.012 * live.current.tilt));
78
+ const sc = 1 - Math.min(0.06, Math.hypot(s.lean.x, s.lean.y) / 30000);
79
+ for (let k = 0; k < slots.length; k++) {
80
+ const el = tiles.current[k];
81
+ if (!el) continue;
82
+ const i = c0 + (k % cols);
83
+ const j = r0 + Math.floor(k / cols);
84
+ // Odd columns sit half a card lower, like a masonry wall.
85
+ const x = i * cw + s.ox;
86
+ const y = j * ch + s.oy + (((i % 2) + 2) % 2) * ch * 0.5;
87
+ el.style.transform = `translate3d(${x.toFixed(2)}px, ${y.toFixed(2)}px, 0) rotateX(${rx.toFixed(2)}deg) rotateY(${ry.toFixed(2)}deg) scale(${sc.toFixed(4)})`;
88
+ const key = `${i},${j}`;
89
+ if (slots[k].key !== key) {
90
+ slots[k].key = key;
91
+ const idx = ((((i * 5 + j * 3) % n) + n) % n);
92
+ el.dataset.index = String(idx);
93
+ const img = el.querySelector("img");
94
+ if (img && img.getAttribute("src") !== items[idx].image) img.setAttribute("src", items[idx].image);
95
+ const label = el.querySelector("[data-title]");
96
+ if (label) label.textContent = items[idx].title ?? "";
97
+ }
98
+ }
99
+ };
100
+
101
+ const frame = (now) => {
102
+ raf = 0;
103
+ const o = live.current;
104
+ const dt = last ? Math.min(0.05, (now - last) / 1000) : 1 / 60;
105
+ last = now;
106
+ if (!s.drag) {
107
+ const k = Math.pow(o.friction, dt * 60);
108
+ s.vx *= k;
109
+ s.vy *= k;
110
+ // Ease back into the idle drift once the throw has died down.
111
+ if (!s.hover && !reduced && o.drift) {
112
+ s.vx += (-o.drift * 0.6 - s.vx) * (1 - Math.exp(-dt / 1.6)) * (Math.abs(s.vx) < 120 ? 1 : 0);
113
+ s.vy += (-o.drift - s.vy) * (1 - Math.exp(-dt / 1.6)) * (Math.abs(s.vy) < 120 ? 1 : 0);
114
+ }
115
+ s.ox += s.vx * dt;
116
+ s.oy += s.vy * dt;
117
+ }
118
+ const lx = s.drag ? s.drag.vx : s.vx;
119
+ const ly = s.drag ? s.drag.vy : s.vy;
120
+ s.lean.x += (lx - s.lean.x) * (1 - Math.exp(-dt / 0.12));
121
+ s.lean.y += (ly - s.lean.y) * (1 - Math.exp(-dt / 0.12));
122
+ paint();
123
+ const moving = s.drag || Math.abs(s.vx) + Math.abs(s.vy) > 1 || Math.abs(s.lean.x) + Math.abs(s.lean.y) > 1;
124
+ if (visible && (moving || (o.drift && !s.hover && !reduced))) raf = requestAnimationFrame(frame);
125
+ else last = 0;
126
+ };
127
+ const start = () => {
128
+ if (!raf && visible) raf = requestAnimationFrame(frame);
129
+ };
130
+
131
+ const onDown = (e) => {
132
+ if (e.button !== 0) return;
133
+ e.preventDefault();
134
+ s.drag = { x: e.clientX, y: e.clientY, ox: s.ox, oy: s.oy, t: performance.now(), vx: 0, vy: 0, moved: false, target: e.target.closest("[data-index]") };
135
+ el.setPointerCapture(e.pointerId);
136
+ s.vx = s.vy = 0;
137
+ start();
138
+ };
139
+ const onMove = (e) => {
140
+ const g = s.drag;
141
+ if (!g) return;
142
+ const now = performance.now();
143
+ const dx = e.clientX - g.x;
144
+ const dy = e.clientY - g.y;
145
+ if (Math.hypot(dx, dy) > 5) g.moved = true;
146
+ const nx = g.ox + dx;
147
+ const ny = g.oy + dy;
148
+ const span = Math.max(8, now - g.t) / 1000;
149
+ g.vx += ((nx - s.ox) / span - g.vx) * 0.4;
150
+ g.vy += ((ny - s.oy) / span - g.vy) * 0.4;
151
+ g.t = now;
152
+ s.ox = nx;
153
+ s.oy = ny;
154
+ start();
155
+ };
156
+ const onUp = (e) => {
157
+ const g = s.drag;
158
+ if (!g) return;
159
+ s.drag = null;
160
+ if (g.moved) {
161
+ // Fling: keep the release speed (capped) and let friction take it.
162
+ s.vx = Math.max(-3000, Math.min(3000, g.vx));
163
+ s.vy = Math.max(-3000, Math.min(3000, g.vy));
164
+ } else if (g.target && e.type === "pointerup") {
165
+ const idx = Number(g.target.dataset.index);
166
+ live.current.onItemClick?.(listRef.current[idx], idx);
167
+ }
168
+ start();
169
+ };
170
+ const onWheel = (e) => {
171
+ e.preventDefault();
172
+ s.vx = -e.deltaX * 6;
173
+ s.vy = -e.deltaY * 6;
174
+ start();
175
+ };
176
+ const onEnter = () => {
177
+ s.hover = true;
178
+ };
179
+ const onLeave = () => {
180
+ s.hover = false;
181
+ start();
182
+ };
183
+ el.addEventListener("pointerdown", onDown);
184
+ el.addEventListener("pointermove", onMove);
185
+ el.addEventListener("pointerup", onUp);
186
+ el.addEventListener("pointercancel", onUp);
187
+ el.addEventListener("pointerenter", onEnter);
188
+ el.addEventListener("pointerleave", onLeave);
189
+ el.addEventListener("wheel", onWheel, { passive: false });
190
+ const io = new IntersectionObserver(([entry]) => {
191
+ visible = entry.isIntersecting;
192
+ if (visible) start();
193
+ });
194
+ io.observe(el);
195
+ paint();
196
+ start();
197
+
198
+ return () => {
199
+ cancelAnimationFrame(raf);
200
+ io.disconnect();
201
+ el.removeEventListener("pointerdown", onDown);
202
+ el.removeEventListener("pointermove", onMove);
203
+ el.removeEventListener("pointerup", onUp);
204
+ el.removeEventListener("pointercancel", onUp);
205
+ el.removeEventListener("pointerenter", onEnter);
206
+ el.removeEventListener("pointerleave", onLeave);
207
+ el.removeEventListener("wheel", onWheel);
208
+ };
209
+ }, [pool]);
210
+
211
+ const { w, h } = geo.current;
212
+ return (
213
+ <div
214
+ ref={wrap}
215
+ className={`relative w-full cursor-grab touch-none select-none overflow-hidden active:cursor-grabbing ${className}`}
216
+ style={{ height, perspective: "1200px", maskImage: "radial-gradient(ellipse 75% 75% at 50% 50%, #000 55%, transparent 100%)", WebkitMaskImage: "radial-gradient(ellipse 75% 75% at 50% 50%, #000 55%, transparent 100%)" }}
217
+ >
218
+ {Array.from({ length: pool }, (_, k) => (
219
+ <div key={k} ref={(e) => (tiles.current[k] = e)} className="group absolute left-0 top-0 will-change-transform" style={{ width: w, height: h }}>
220
+ <div className="h-full w-full overflow-hidden rounded-2xl bg-white/5 shadow-[0_18px_40px_-22px_rgba(0,0,0,0.7)] ring-1 ring-white/10">
221
+ <img alt="" draggable={false} loading="lazy" decoding="async" className="h-full w-full object-cover transition-transform duration-500 ease-out group-hover:scale-[1.06]" />
222
+ </div>
223
+ {showTitles && <p data-title="" className="pointer-events-none absolute inset-x-3 bottom-3 truncate rounded-full bg-black/45 px-3 py-1 text-xs font-semibold text-white opacity-0 backdrop-blur transition-opacity duration-300 group-hover:opacity-100" />}
224
+ </div>
225
+ ))}
226
+ </div>
227
+ );
228
+ };
229
+
230
+ export default InfiniteCanvas;
@@ -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;
@@ -6,7 +6,8 @@ import { Camera, Heart, Home, MessageCircle, Plus, Settings, Share2, User } from
6
6
  * Radial Menu: a round button that fans its actions out in a circle (or an
7
7
  * arc). Items spring out one after another and tuck back in on close.
8
8
  * Keyboard friendly: arrows move between items, Escape closes, and clicking
9
- * outside closes too.
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.
10
11
  */
11
12
 
12
13
  const DEFAULT_ITEMS = [
@@ -25,11 +26,24 @@ const RadialMenu = ({ items = DEFAULT_ITEMS, radius = 6.5, arc = 360, startAngle
25
26
  const [picked, setPicked] = useState(null);
26
27
  const root = useRef(null);
27
28
  const btns = useRef([]);
29
+ const [fitR, setFitR] = useState(null);
28
30
  const dark = theme !== "light";
29
31
  const n = items.length;
30
32
  // Full circle spreads evenly; a partial arc includes both ends.
31
33
  const step = arc >= 360 ? 360 / n : arc / Math.max(1, n - 1);
32
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
+
33
47
  useEffect(() => {
34
48
  if (!open) return undefined;
35
49
  const down = (e) => !root.current?.contains(e.target) && setOpen(false);
@@ -56,11 +70,13 @@ const RadialMenu = ({ items = DEFAULT_ITEMS, radius = 6.5, arc = 360, startAngle
56
70
 
57
71
  return (
58
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>
59
74
  <AnimatePresence>
60
75
  {open &&
61
76
  items.map((item, i) => {
62
77
  const a = ((startAngle + i * step) * Math.PI) / 180;
63
78
  const Icon = item.icon;
79
+ const r = fitR ?? radius * 16;
64
80
  return (
65
81
  <motion.button
66
82
  key={`${id}-${i}`}
@@ -71,10 +87,10 @@ const RadialMenu = ({ items = DEFAULT_ITEMS, radius = 6.5, arc = 360, startAngle
71
87
  onClick={() => choose(item, i)}
72
88
  onKeyDown={(e) => onItemKey(e, i)}
73
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"}`}
74
- initial={{ x: 0, y: 0, scale: 0.3, opacity: 0 }}
75
- animate={{ x: `${Math.cos(a) * radius}rem`, y: `${Math.sin(a) * radius}rem`, scale: 1, opacity: 1, transition: { type: "spring", stiffness: 420, damping: 24, delay: i * 0.03 } }}
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 } }}
76
92
  exit={{ x: 0, y: 0, scale: 0.3, opacity: 0, transition: { duration: 0.18, delay: (n - 1 - i) * 0.015 } }}
77
- style={picked === i ? { color: accentColor } : undefined}
93
+ style={{ animation: `apex-radial-in 220ms ease-out ${i * 30}ms backwards`, ...(picked === i ? { color: accentColor } : null) }}
78
94
  >
79
95
  {Icon ? <Icon className="h-5 w-5" /> : item.label[0]}
80
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>
@@ -1,15 +1,18 @@
1
- import React, { useRef } from "react";
1
+ import React, { useEffect, useRef } from "react";
2
2
  import { useAnimationFrame, useReducedMotion } from "framer-motion";
3
3
 
4
4
  /*
5
5
  * 3D Text Ring: letters stand on a ring in real 3D space and the ring
6
6
  * turns, so text sweeps around the back and comes forward again. Letters
7
- * fade and soften as they pass behind. Drag to spin it yourself.
7
+ * fade and soften as they pass behind. Drag to spin it yourself. On narrow
8
+ * screens the whole ring scales down to fit its container.
8
9
  */
9
10
 
10
11
  const TextRing3D = ({ text = "APEX UI · 3D TYPE · ", radius = 9, speed = 30, tilt = -14, fontSize = 2, color = "currentColor", accentColor = "#a3e635", className = "" }) => {
11
12
  const reduced = useReducedMotion();
13
+ const box = useRef(null);
12
14
  const ring = useRef(null);
15
+ const fit = useRef(1);
13
16
  const letters = useRef([]);
14
17
  const angle = useRef(0);
15
18
  const vel = useRef(0);
@@ -17,13 +20,24 @@ const TextRing3D = ({ text = "APEX UI · 3D TYPE · ", radius = 9, speed = 30, t
17
20
  const chars = Array.from(text);
18
21
  const step = 360 / chars.length;
19
22
 
23
+ useEffect(() => {
24
+ const el = box.current;
25
+ const ro = new ResizeObserver(() => {
26
+ const rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
27
+ const want = radius * 2.4 * rem;
28
+ fit.current = Math.min(1, el.getBoundingClientRect().width / want);
29
+ });
30
+ ro.observe(el);
31
+ return () => ro.disconnect();
32
+ }, [radius]);
33
+
20
34
  useAnimationFrame((_, delta) => {
21
35
  const dt = Math.min(0.05, delta / 1000);
22
36
  if (!drag.current) {
23
37
  vel.current *= 0.95;
24
38
  angle.current += (reduced ? 0 : speed) * dt + vel.current * dt;
25
39
  }
26
- if (ring.current) ring.current.style.transform = `rotateX(${tilt}deg) rotateY(${-angle.current}deg)`;
40
+ if (ring.current) ring.current.style.transform = `scale(${fit.current}) rotateX(${tilt}deg) rotateY(${-angle.current}deg)`;
27
41
  letters.current.forEach((el, i) => {
28
42
  if (!el) return;
29
43
  // How much this letter faces the viewer (1 front, -1 back).
@@ -35,6 +49,7 @@ const TextRing3D = ({ text = "APEX UI · 3D TYPE · ", radius = 9, speed = 30, t
35
49
 
36
50
  return (
37
51
  <div
52
+ ref={box}
38
53
  className={`grid cursor-grab touch-none select-none place-items-center active:cursor-grabbing [perspective:900px] ${className}`}
39
54
  style={{ width: `${radius * 2.4}rem`, maxWidth: "100%", height: `${fontSize * 3.2 + 4}rem`, color }}
40
55
  onPointerDown={(e) => {