apex-ui-kit 2.6.0 → 2.7.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,259 @@
1
+ import React, { useEffect, useRef } from "react";
2
+ import { Color, Mesh, OrthographicCamera, PlaneGeometry, Scene, ShaderMaterial, Vector2, WebGLRenderer } from "three";
3
+
4
+ /*
5
+ * Horizon Glow: a soft, wide light rising from below the bottom edge, fading
6
+ * from a pale core through an accent band and a mid tone into a deep sky,
7
+ * drawn in one WebGL shader. A thin luminous arc marks the horizon, faint
8
+ * volumetric rays climb out of it and specks of dust drift up through the
9
+ * light. It rises in on mount, breathes slowly and leans toward the pointer;
10
+ * a filmic tone curve and dithering keep the long gradient silky.
11
+ */
12
+
13
+ const PRESETS = {
14
+ lilac: { sky: "#0d1438", mid: "#6f4c86", accent: "#ff9fd2", core: "#fff1f8" },
15
+ ocean: { sky: "#020f1c", mid: "#195a78", accent: "#58e1ff", core: "#e9fbff" },
16
+ ember: { sky: "#100606", mid: "#7a2f1e", accent: "#ff9b54", core: "#fff0dc" },
17
+ mint: { sky: "#04131a", mid: "#22625a", accent: "#6dffc4", core: "#ecfff6" },
18
+ mono: { sky: "#08080a", mid: "#45454d", accent: "#c9c9d6", core: "#ffffff" },
19
+ };
20
+
21
+ const VERT = `void main(){ gl_Position = vec4(position.xy, 0.0, 1.0); }`;
22
+
23
+ const FRAG = `
24
+ precision highp float;
25
+ uniform vec2 uRes; uniform float uTime; uniform float uRise; uniform vec2 uMouse;
26
+ uniform vec3 uSky; uniform vec3 uMid; uniform vec3 uAccent; uniform vec3 uCore;
27
+ uniform float uSpread; uniform float uIntensity; uniform float uShimmer; uniform float uBreath; uniform float uGrain;
28
+ uniform float uArc; uniform float uRays; uniform float uDust;
29
+
30
+ float h2(vec2 p){ return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
31
+ float n2(vec2 p){
32
+ vec2 i = floor(p); vec2 f = fract(p); vec2 u = f * f * (3.0 - 2.0 * f);
33
+ return mix(mix(h2(i), h2(i + vec2(1, 0)), u.x), mix(h2(i + vec2(0, 1)), h2(i + vec2(1, 1)), u.x), u.y);
34
+ }
35
+ float fbm(vec2 p){ float v = 0.0; float a = 0.5; for (int i = 0; i < 4; i++) { v += a * n2(p); p *= 2.02; a *= 0.5; } return v; }
36
+
37
+ // Drifting dust: one speck per cell, rising slowly and twinkling.
38
+ float dust(vec2 p, float scale, float speed, float seed){
39
+ vec2 q = p * scale + vec2(seed, -uTime * speed);
40
+ vec2 id = floor(q); vec2 f = fract(q) - 0.5;
41
+ vec2 o = vec2(h2(id + seed), h2(id + seed + 3.1)) - 0.5;
42
+ float r = length(f - o * 0.7);
43
+ float tw = 0.5 + 0.5 * sin(uTime * (1.5 + h2(id) * 2.5) + h2(id + 9.0) * 6.28);
44
+ return smoothstep(0.06, 0.0, r) * step(0.55, h2(id + seed + 7.7)) * tw;
45
+ }
46
+
47
+ void main(){
48
+ vec2 uv = gl_FragCoord.xy / uRes;
49
+ float asp = uRes.x / uRes.y;
50
+ // Work in height units; wide screens get a wider glow, phones a taller one.
51
+ vec2 p = vec2((uv.x - 0.5) * asp, uv.y);
52
+ float width = max(asp, 0.8) * 0.82;
53
+
54
+ float rise = uRise;
55
+ float breath = 1.0 + uBreath * 0.035 * sin(uTime * 0.55) + uBreath * 0.015 * sin(uTime * 1.3 + 1.7);
56
+ vec2 c = vec2(uMouse.x * 0.06 * asp, mix(-1.1, -0.06, rise) + uMouse.y * 0.025);
57
+
58
+ // Elliptical distance from the light source below the edge.
59
+ vec2 e = (p - c) / vec2(width, 1.0);
60
+ float d0 = length(e);
61
+ // A slow shimmer that bends the falloff a little.
62
+ float s = fbm(vec2(p.x * 1.4, p.y * 1.1 - uTime * 0.035) + uTime * 0.02) - 0.5;
63
+ float d = d0 + s * 0.12 * uShimmer * smoothstep(1.4, 0.3, d0);
64
+
65
+ float reach = uSpread * breath * mix(0.35, 1.0, rise);
66
+ float t = clamp(d / reach, 0.0, 1.0);
67
+
68
+ // Core -> accent band -> mid tone -> sky.
69
+ vec3 col = mix(uCore, uMid, smoothstep(0.0, 0.62, t));
70
+ col = mix(col, uSky, smoothstep(0.38, 1.0, t));
71
+ float band = exp(-pow((t - 0.3) / 0.13, 2.0));
72
+ col = mix(col, col * 0.55 + uAccent * 0.55, band * 0.45);
73
+ col += uCore * exp(-d * d * 9.0 / (reach * reach)) * 0.25 * uIntensity;
74
+
75
+ // Volumetric rays climbing out of the source.
76
+ float ang = atan(p.x - c.x, (p.y - c.y) * 1.6);
77
+ float beams = n2(vec2(ang * 8.0, uTime * 0.07)) * 0.65 + n2(vec2(ang * 21.0, -uTime * 0.05)) * 0.35;
78
+ col += mix(uAccent, uCore, 0.5) * smoothstep(0.5, 1.0, beams) * exp(-t * 2.4) * smoothstep(0.02, 0.2, t) * 0.28 * uRays;
79
+
80
+ // Horizon arc: a thin bright rim where the light meets the edge of the world.
81
+ float pr = 0.9;
82
+ vec2 pc = vec2(c.x, c.y + 0.24 - pr);
83
+ float rd = length((p - pc) / vec2(max(asp, 0.8) * 1.05, 1.0)) - pr;
84
+ float fade = exp(-abs(p.x - c.x) / (width * 0.9));
85
+ col += mix(uAccent, uCore, 0.7) * (exp(-abs(rd) * 140.0) * 0.9 + exp(-max(rd, 0.0) * 14.0) * 0.18) * fade * uArc * rise;
86
+ col *= mix(1.0, 0.82, smoothstep(0.0, -0.08, rd) * uArc);
87
+
88
+ // Dust lit by the glow.
89
+ float lit = exp(-t * 2.2);
90
+ col += uCore * (dust(p, 9.0, 0.05, 1.0) + dust(p, 16.0, 0.09, 7.0) * 0.7) * lit * 0.55 * uDust * rise;
91
+
92
+ col = mix(uSky, col, clamp(uIntensity * (0.25 + 0.75 * rise), 0.0, 1.4));
93
+ // Darker corners at the top keep text readable.
94
+ col *= 1.0 - 0.12 * smoothstep(0.45, 1.1, uv.y) * smoothstep(0.1, 0.9, abs(uv.x - 0.5) * 2.0);
95
+ // Filmic shoulder so the core rolls off instead of clipping.
96
+ col = col / (1.0 + max(col - 0.85, 0.0));
97
+
98
+ col += (h2(gl_FragCoord.xy + fract(uTime * 7.0) * 91.0) - 0.5) * (1.0 + 7.0 * uGrain) / 255.0;
99
+ gl_FragColor = vec4(col, 1.0);
100
+ }`;
101
+
102
+ const HorizonGlow = ({
103
+ preset = "lilac",
104
+ skyColor,
105
+ midColor,
106
+ accentColor,
107
+ coreColor,
108
+ spread = 1.3,
109
+ intensity = 1,
110
+ duration = 2.4,
111
+ autoRise = true,
112
+ breathe = 1,
113
+ shimmer = 1,
114
+ arc = 0.7,
115
+ rays = 1,
116
+ dust = 1,
117
+ grain = 0.4,
118
+ speed = 1,
119
+ interactive = true,
120
+ replay = 0,
121
+ className = "",
122
+ children,
123
+ }) => {
124
+ const wrap = useRef(null);
125
+ const p = PRESETS[preset] ?? PRESETS.lilac;
126
+ const live = useRef({});
127
+ live.current = { sky: skyColor ?? p.sky, mid: midColor ?? p.mid, accent: accentColor ?? p.accent, core: coreColor ?? p.core, spread, intensity, duration, autoRise, breathe, shimmer, arc, rays, dust, grain, speed, interactive };
128
+ const rise = useRef({ start: 0 });
129
+
130
+ useEffect(() => {
131
+ rise.current.start = performance.now();
132
+ }, [replay, autoRise]);
133
+
134
+ useEffect(() => {
135
+ const el = wrap.current;
136
+ const renderer = new WebGLRenderer({ antialias: false, alpha: false, powerPreference: "high-performance" });
137
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.5));
138
+ Object.assign(renderer.domElement.style, { position: "absolute", inset: "0", width: "100%", height: "100%", display: "block" });
139
+ el.prepend(renderer.domElement);
140
+ const scene = new Scene();
141
+ const camera = new OrthographicCamera(-1, 1, 1, -1, 0, 1);
142
+ const u = {
143
+ uRes: { value: new Vector2(1, 1) },
144
+ uTime: { value: 0 },
145
+ uRise: { value: 0 },
146
+ uMouse: { value: new Vector2() },
147
+ uSky: { value: new Color() },
148
+ uMid: { value: new Color() },
149
+ uAccent: { value: new Color() },
150
+ uCore: { value: new Color() },
151
+ uSpread: { value: 1 },
152
+ uIntensity: { value: 1 },
153
+ uShimmer: { value: 1 },
154
+ uBreath: { value: 1 },
155
+ uGrain: { value: 0.4 },
156
+ uArc: { value: 0.7 },
157
+ uRays: { value: 1 },
158
+ uDust: { value: 1 },
159
+ };
160
+ const material = new ShaderMaterial({ vertexShader: VERT, fragmentShader: FRAG, uniforms: u, depthTest: false, depthWrite: false });
161
+ const geometry = new PlaneGeometry(2, 2);
162
+ scene.add(new Mesh(geometry, material));
163
+
164
+ const reduced = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
165
+ const mouse = { x: 0, y: 0, tx: 0, ty: 0 };
166
+ let raf = 0;
167
+ let last = 0;
168
+ let time = 0;
169
+ let visible = true;
170
+
171
+ const draw = (now) => {
172
+ const o = live.current;
173
+ const dt = last ? Math.min(0.05, (now - last) / 1000) : 1 / 60;
174
+ last = now;
175
+ time += dt * o.speed;
176
+ const t = o.autoRise && !reduced ? Math.min(1, (now - rise.current.start) / (Math.max(0.1, o.duration) * 1000)) : 1;
177
+ // Ease out with a touch of overshoot-free settle.
178
+ u.uRise.value = 1 - Math.pow(1 - t, 4);
179
+ const k = 1 - Math.exp(-dt / 0.6);
180
+ mouse.x += ((o.interactive ? mouse.tx : 0) - mouse.x) * k;
181
+ mouse.y += ((o.interactive ? mouse.ty : 0) - mouse.y) * k;
182
+ u.uMouse.value.set(mouse.x, mouse.y);
183
+ u.uTime.value = time;
184
+ u.uSky.value.set(o.sky);
185
+ u.uMid.value.set(o.mid);
186
+ u.uAccent.value.set(o.accent);
187
+ u.uCore.value.set(o.core);
188
+ u.uSpread.value = o.spread;
189
+ u.uIntensity.value = o.intensity;
190
+ u.uShimmer.value = reduced ? 0 : o.shimmer;
191
+ u.uBreath.value = reduced ? 0 : o.breathe;
192
+ u.uGrain.value = o.grain;
193
+ u.uArc.value = o.arc;
194
+ u.uRays.value = reduced ? o.rays * 0.6 : o.rays;
195
+ u.uDust.value = reduced ? 0 : o.dust;
196
+ renderer.render(scene, camera);
197
+ };
198
+ const loop = (now) => {
199
+ raf = 0;
200
+ draw(now);
201
+ if (visible && !reduced) raf = requestAnimationFrame(loop);
202
+ };
203
+ const start = () => {
204
+ if (!raf && visible && !reduced) {
205
+ last = 0;
206
+ raf = requestAnimationFrame(loop);
207
+ }
208
+ };
209
+ const fit = () => {
210
+ const { width, height } = el.getBoundingClientRect();
211
+ if (!width || !height) return;
212
+ renderer.setSize(width, height, false);
213
+ renderer.getDrawingBufferSize(u.uRes.value);
214
+ if (!raf) draw(performance.now());
215
+ };
216
+
217
+ const onMove = (e) => {
218
+ const r = el.getBoundingClientRect();
219
+ mouse.tx = ((e.clientX - r.left) / r.width) * 2 - 1;
220
+ mouse.ty = -(((e.clientY - r.top) / r.height) * 2 - 1);
221
+ };
222
+ const onLeave = () => {
223
+ mouse.tx = 0;
224
+ mouse.ty = 0;
225
+ };
226
+ el.addEventListener("pointermove", onMove);
227
+ el.addEventListener("pointerleave", onLeave);
228
+ const ro = new ResizeObserver(fit);
229
+ ro.observe(el);
230
+ const io = new IntersectionObserver(([entry]) => {
231
+ visible = entry.isIntersecting;
232
+ if (visible) start();
233
+ });
234
+ io.observe(el);
235
+ fit();
236
+ start();
237
+
238
+ return () => {
239
+ cancelAnimationFrame(raf);
240
+ ro.disconnect();
241
+ io.disconnect();
242
+ el.removeEventListener("pointermove", onMove);
243
+ el.removeEventListener("pointerleave", onLeave);
244
+ geometry.dispose();
245
+ material.dispose();
246
+ renderer.dispose();
247
+ renderer.forceContextLoss();
248
+ renderer.domElement.remove();
249
+ };
250
+ }, []);
251
+
252
+ return (
253
+ <div ref={wrap} className={`relative h-full w-full overflow-hidden bg-[#0d1438] ${className}`}>
254
+ {children && <div className="relative z-10 h-full w-full">{children}</div>}
255
+ </div>
256
+ );
257
+ };
258
+
259
+ export default HorizonGlow;
@@ -0,0 +1,69 @@
1
+ import React, { useRef } from "react";
2
+ import { motion, useMotionValue, useReducedMotion, useSpring, useTransform } from "framer-motion";
3
+
4
+ /*
5
+ * Magnetic Button: when the pointer comes near, the button leans toward it
6
+ * like a magnet, and its label follows a little further for a layered
7
+ * parallax. A soft glow tracks the pointer across the surface. Springs back
8
+ * when the pointer leaves. Renders a <button>, or a link with `as="a"`.
9
+ */
10
+
11
+ const MagneticButton = ({ as = "button", children = "Get started", strength = 0.35, radius = 1.6, accentColor = "#a3e635", theme = "dark", className = "", ...rest }) => {
12
+ const reduced = useReducedMotion();
13
+ const ref = useRef(null);
14
+ const mx = useMotionValue(0);
15
+ const my = useMotionValue(0);
16
+ const gx = useMotionValue(50);
17
+ const gy = useMotionValue(50);
18
+ const spring = { stiffness: 220, damping: 18, mass: 0.6 };
19
+ const x = useSpring(mx, spring);
20
+ const y = useSpring(my, spring);
21
+ // The label travels a bit further than the button: layered parallax.
22
+ const lx = useTransform(x, (v) => v * 0.45);
23
+ const ly = useTransform(y, (v) => v * 0.45);
24
+ const glow = useTransform([gx, gy], ([a, b]) => `radial-gradient(120px circle at ${a}% ${b}%, ${accentColor}55, transparent 70%)`);
25
+ const Tag = motion[as] ?? motion.button;
26
+ const dark = theme !== "light";
27
+
28
+ const onMove = (e) => {
29
+ const el = ref.current;
30
+ if (!el || reduced) return;
31
+ const r = el.getBoundingClientRect();
32
+ const cx = r.left + r.width / 2;
33
+ const cy = r.top + r.height / 2;
34
+ const dx = e.clientX - cx;
35
+ const dy = e.clientY - cy;
36
+ // Only pull while the pointer is within `radius` × the button size.
37
+ const reach = Math.max(r.width, r.height) * radius;
38
+ const pull = Math.max(0, 1 - Math.hypot(dx, dy) / reach);
39
+ mx.set(dx * strength * pull * 1.6);
40
+ my.set(dy * strength * pull * 1.6);
41
+ gx.set(((e.clientX - r.left) / r.width) * 100);
42
+ gy.set(((e.clientY - r.top) / r.height) * 100);
43
+ };
44
+ const reset = () => {
45
+ mx.set(0);
46
+ my.set(0);
47
+ };
48
+
49
+ return (
50
+ // The wrapper is larger than the button so the pull starts before you touch it.
51
+ <span className="inline-block p-6" onPointerMove={onMove} onPointerLeave={reset}>
52
+ <Tag
53
+ ref={ref}
54
+ {...(as === "button" ? { type: "button" } : {})}
55
+ {...rest}
56
+ style={{ x, y }}
57
+ whileTap={{ scale: 0.96 }}
58
+ className={`group relative inline-flex select-none items-center justify-center overflow-hidden rounded-full px-8 py-4 text-[0.95rem] font-semibold outline-none ring-1 transition-shadow focus-visible:ring-2 ${dark ? "bg-neutral-950 text-white ring-white/15 focus-visible:ring-white/40" : "bg-white text-neutral-900 ring-black/10 focus-visible:ring-black/30"} ${className}`}
59
+ >
60
+ <motion.span aria-hidden="true" className="pointer-events-none absolute inset-0 opacity-0 transition-opacity duration-300 group-hover:opacity-100" style={{ background: glow }} />
61
+ <motion.span className="relative inline-flex items-center gap-2" style={{ x: lx, y: ly }}>
62
+ {children}
63
+ </motion.span>
64
+ </Tag>
65
+ </span>
66
+ );
67
+ };
68
+
69
+ export default MagneticButton;
@@ -0,0 +1,68 @@
1
+ import React from "react";
2
+ import { motion, useAnimationFrame, useMotionValue, useReducedMotion, useTransform } from "framer-motion";
3
+
4
+ /*
5
+ * Marquee 3D: a wall of cards tilted back in 3D, with columns that scroll in
6
+ * opposite directions forever. Great for showing off screenshots, logos or
7
+ * testimonials. Each column repeats its items twice and moves exactly one
8
+ * copy, so the loop has no seam. Hover pauses; edges fade out.
9
+ */
10
+
11
+ const IK = "https://ik.imagekit.io/ny6yinyut/apexui/assets/componentsimage";
12
+ const shot = (f) => `${IK}/${f}?tr=f-auto,q-75,w-420`;
13
+ const DEFAULT_IMAGES = [
14
+ "gooey-nav-v3.png", "liquid-blob-3d.png", "galaxy-3d.png", "warp-tunnel.png", "retro-grid-3d.png", "particle-morph.png",
15
+ "distortion-image.png", "cube-gallery-v2.png", "coverflow-3d.png", "ai-voice-orb.png", "flip-book-v2.png", "holo-card.png",
16
+ "dot-globe.png", "3d-carousel.png", "pricing-cards.png", "stepper.png",
17
+ ].map(shot);
18
+
19
+ function Column({ images, duration, reverse, paused }) {
20
+ const reduced = useReducedMotion();
21
+ const list = [...images, ...images];
22
+ // Progress through one copy (0 to 50%), advanced every frame. Pausing just
23
+ // stops advancing, so hover-out continues from the same spot (no jump).
24
+ const offset = useMotionValue(0);
25
+ useAnimationFrame((_, delta) => {
26
+ if (paused || reduced) return;
27
+ offset.set((offset.get() + (Math.min(delta, 50) / 1000 / duration) * 50) % 50);
28
+ });
29
+ const y = useTransform(offset, (v) => `${reverse ? v - 50 : -v}%`);
30
+ return (
31
+ <div className="relative h-full overflow-hidden">
32
+ <motion.div className="flex flex-col gap-4" style={{ y }}>
33
+ {list.map((src, i) => (
34
+ <img key={i} src={src} alt="" draggable="false" loading="lazy" className="aspect-[4/3] w-full rounded-xl bg-neutral-900 object-cover shadow-[0_10px_30px_-12px_rgba(0,0,0,0.6)] ring-1 ring-white/10" />
35
+ ))}
36
+ </motion.div>
37
+ </div>
38
+ );
39
+ }
40
+
41
+ const Marquee3D = ({ images = DEFAULT_IMAGES, columns = 4, speed = 1, tilt = 1, pauseOnHover = true, height = "100%", className = "" }) => {
42
+ const [hover, setHover] = React.useState(false);
43
+ const cols = Array.from({ length: columns }, (_, c) => images.filter((_, i) => i % columns === c));
44
+ return (
45
+ <div
46
+ className={`relative w-full overflow-hidden [perspective:1000px] ${className}`}
47
+ style={{ height, maskImage: "linear-gradient(to bottom, transparent, black 18%, black 82%, transparent)", WebkitMaskImage: "linear-gradient(to bottom, transparent, black 18%, black 82%, transparent)" }}
48
+ onPointerEnter={() => pauseOnHover && setHover(true)}
49
+ onPointerLeave={() => setHover(false)}
50
+ aria-hidden="true"
51
+ >
52
+ <div
53
+ className="absolute left-1/2 top-1/2 grid h-[170%] w-[115%] gap-4"
54
+ style={{
55
+ gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
56
+ transform: `translate(-50%, -50%) rotateX(${32 * tilt}deg) rotateZ(${-22 * tilt}deg) rotateY(${8 * tilt}deg)`,
57
+ transformStyle: "preserve-3d",
58
+ }}
59
+ >
60
+ {cols.map((imgs, c) => (
61
+ <Column key={c} images={imgs.length ? imgs : images} duration={(28 + c * 6) / speed} reverse={c % 2 === 1} paused={hover} />
62
+ ))}
63
+ </div>
64
+ </div>
65
+ );
66
+ };
67
+
68
+ export default Marquee3D;
@@ -1,6 +1,6 @@
1
1
  import React, { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from 'react';
2
2
  import { AnimatePresence, LayoutGroup, MotionConfig, motion } from 'framer-motion';
3
- import { ArrowRight, ArrowUpRight, ChevronDown } from 'lucide-react';
3
+ import { ArrowRight, ArrowUpRight, ChevronDown, Menu, X } from 'lucide-react';
4
4
 
5
5
  const ease = [0.22, 1, 0.36, 1];
6
6
  const CLOSE_DELAY = 140;
@@ -70,7 +70,129 @@ const PanelContent = ({ item }) => {
70
70
  return <Services items={item.submenu} />;
71
71
  };
72
72
 
73
+ /*
74
+ * Phones: a Menu button opens one full-width sheet; each section expands in
75
+ * place (accordion) instead of a hover panel that would not fit.
76
+ */
77
+ const MobileNav = ({ navData }) => {
78
+ const [open, setOpen] = useState(false);
79
+ const [section, setSection] = useState(null);
80
+ const root = useRef(null);
81
+ useEffect(() => {
82
+ if (!open) return undefined;
83
+ const down = (e) => !root.current?.contains(e.target) && setOpen(false);
84
+ const key = (e) => e.key === 'Escape' && setOpen(false);
85
+ window.addEventListener('pointerdown', down);
86
+ window.addEventListener('keydown', key);
87
+ return () => {
88
+ window.removeEventListener('pointerdown', down);
89
+ window.removeEventListener('keydown', key);
90
+ };
91
+ }, [open]);
92
+ return (
93
+ <div ref={root} className="relative w-full max-w-sm">
94
+ <button
95
+ type="button"
96
+ aria-expanded={open}
97
+ onClick={() => setOpen((o) => !o)}
98
+ className="flex w-full items-center justify-between rounded-full border border-white/10 bg-zinc-950/85 px-5 py-3 text-sm font-medium text-zinc-200 shadow-[0_10px_40px_-12px_rgba(0,0,0,0.7)] outline-none backdrop-blur-xl focus-visible:ring-2 focus-visible:ring-lime-400/60"
99
+ >
100
+ Menu
101
+ {open ? <X size={18} /> : <Menu size={18} />}
102
+ </button>
103
+ <AnimatePresence>
104
+ {open && (
105
+ <motion.nav
106
+ aria-label="Main"
107
+ initial={{ opacity: 0, y: -8, scale: 0.98 }}
108
+ animate={{ opacity: 1, y: 0, scale: 1 }}
109
+ exit={{ opacity: 0, y: -6, scale: 0.98, transition: { duration: 0.15 } }}
110
+ transition={{ duration: 0.22, ease }}
111
+ className="absolute left-0 right-0 top-full z-10 mt-2 max-h-[70vh] origin-top overflow-y-auto rounded-2xl border border-white/10 bg-zinc-950/95 p-2 shadow-[0_24px_60px_-20px_rgba(0,0,0,0.8)] backdrop-blur-xl"
112
+ >
113
+ {navData.map((item, i) => {
114
+ const hasMenu = Boolean(item.submenu?.length);
115
+ const isOpen = section === i;
116
+ if (!hasMenu) {
117
+ return (
118
+ <a key={i} href={item.href || '#'} className="block rounded-xl px-3 py-3 text-sm font-medium text-zinc-200 outline-none hover:bg-white/[0.05] focus-visible:bg-white/[0.07]">
119
+ {item.label}
120
+ </a>
121
+ );
122
+ }
123
+ return (
124
+ <div key={i}>
125
+ <button
126
+ type="button"
127
+ aria-expanded={isOpen}
128
+ onClick={() => setSection(isOpen ? null : i)}
129
+ className={`flex w-full items-center justify-between rounded-xl px-3 py-3 text-sm font-medium outline-none hover:bg-white/[0.05] focus-visible:bg-white/[0.07] ${isOpen ? 'text-lime-300' : 'text-zinc-200'}`}
130
+ >
131
+ {item.label}
132
+ <ChevronDown size={16} className={`transition-transform duration-300 ${isOpen ? 'rotate-180' : ''}`} />
133
+ </button>
134
+ <AnimatePresence initial={false}>
135
+ {isOpen && (
136
+ <motion.ul
137
+ initial={{ height: 0, opacity: 0 }}
138
+ animate={{ height: 'auto', opacity: 1 }}
139
+ exit={{ height: 0, opacity: 0 }}
140
+ transition={{ duration: 0.25, ease }}
141
+ className="overflow-hidden pl-2"
142
+ >
143
+ {item.submenu.map((sub, j) => (
144
+ <li key={j}>
145
+ <a href={sub.href} className="flex items-center gap-3 rounded-lg px-3 py-2 outline-none hover:bg-white/[0.05] focus-visible:bg-white/[0.07]">
146
+ {sub.image ? (
147
+ <img src={sub.image} alt="" className="h-9 w-9 shrink-0 rounded-md object-cover ring-1 ring-white/10" />
148
+ ) : (
149
+ <span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-lime-400/20 bg-lime-400/10 text-xs font-semibold text-lime-300">
150
+ {sub.icon ? <sub.icon size={14} /> : sub.label.charAt(0)}
151
+ </span>
152
+ )}
153
+ <span className="min-w-0">
154
+ <span className="block truncate text-sm text-zinc-100">{sub.label}</span>
155
+ {sub.subtitle && <span className="block truncate text-xs text-zinc-500">{sub.subtitle}</span>}
156
+ </span>
157
+ </a>
158
+ </li>
159
+ ))}
160
+ </motion.ul>
161
+ )}
162
+ </AnimatePresence>
163
+ </div>
164
+ );
165
+ })}
166
+ </motion.nav>
167
+ )}
168
+ </AnimatePresence>
169
+ </div>
170
+ );
171
+ };
172
+
173
+ // Below this much room the desktop bar would not fit, so the phone menu shows.
174
+ const COMPACT_BELOW = 640;
175
+
73
176
  const NavDropDown = ({ navData = [], className = '' }) => {
177
+ const hostRef = useRef(null);
178
+ const [compact, setCompact] = useState(false);
179
+ useLayoutEffect(() => {
180
+ const host = hostRef.current?.parentElement;
181
+ if (!host) return undefined;
182
+ const check = () => setCompact(host.clientWidth < COMPACT_BELOW);
183
+ check();
184
+ const ro = new ResizeObserver(check);
185
+ ro.observe(host);
186
+ return () => ro.disconnect();
187
+ }, []);
188
+ return (
189
+ <div ref={hostRef} className={`flex w-full justify-center ${className}`}>
190
+ {compact ? <MobileNav navData={navData} /> : <DesktopNav navData={navData} />}
191
+ </div>
192
+ );
193
+ };
194
+
195
+ const DesktopNav = ({ navData = [], className = '' }) => {
74
196
  const uid = useId();
75
197
  const rootRef = useRef(null);
76
198
  const barRef = useRef(null);