apex-ui-kit 2.1.3 → 2.3.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 (64) hide show
  1. package/dist/ApexUI-Kit/templates/AiChat/AiChat.jsx +171 -0
  2. package/dist/ApexUI-Kit/templates/AiModelPrompt/AiModelPrompt.jsx +221 -0
  3. package/dist/ApexUI-Kit/templates/AiModelPrompt/index.js +2 -0
  4. package/dist/ApexUI-Kit/templates/AiModelPrompt/models.js +15 -0
  5. package/dist/ApexUI-Kit/templates/AiPromptBox/AiPromptBox.jsx +173 -0
  6. package/dist/ApexUI-Kit/templates/AiResponse/AiResponse.jsx +172 -0
  7. package/dist/ApexUI-Kit/templates/AiThinking/AiThinking.jsx +106 -0
  8. package/dist/ApexUI-Kit/templates/AiVoiceBars/AiVoiceBars.jsx +78 -0
  9. package/dist/ApexUI-Kit/templates/AiVoiceBlob/AiVoiceBlob.jsx +105 -0
  10. package/dist/ApexUI-Kit/templates/AiVoiceOrb/AiVoiceOrb.jsx +74 -0
  11. package/dist/ApexUI-Kit/templates/AiVoiceRing/AiVoiceRing.jsx +120 -0
  12. package/dist/ApexUI-Kit/templates/AiVoiceWave/AiVoiceWave.jsx +107 -0
  13. package/dist/ApexUI-Kit/templates/AnimatedButton/AnimatedButton.jsx +251 -0
  14. package/dist/ApexUI-Kit/templates/AnimatedSwitch/AnimatedSwitch.jsx +80 -0
  15. package/dist/ApexUI-Kit/templates/AnimatedTabs/AnimatedTabs.jsx +107 -0
  16. package/dist/ApexUI-Kit/templates/AuroraBackground/AuroraBackground.jsx +48 -0
  17. package/dist/ApexUI-Kit/templates/BlurRevealText/BlurRevealText.jsx +60 -0
  18. package/dist/ApexUI-Kit/templates/BounceCards/BounceCards.jsx +53 -0
  19. package/dist/ApexUI-Kit/templates/CardSwap3D/CardSwap3D.jsx +80 -0
  20. package/dist/ApexUI-Kit/templates/Carousel3D/Carousel3D.jsx +222 -0
  21. package/dist/ApexUI-Kit/templates/CircularText/CircularText.jsx +49 -0
  22. package/dist/ApexUI-Kit/templates/ClickSpark/ClickSpark.jsx +72 -0
  23. package/dist/ApexUI-Kit/templates/Coverflow3D/Coverflow3D.jsx +108 -0
  24. package/dist/ApexUI-Kit/templates/CubeGallery/CubeGallery.jsx +166 -0
  25. package/dist/ApexUI-Kit/templates/CursorTrail/CursorTrail.jsx +200 -0
  26. package/dist/ApexUI-Kit/templates/DotGlobe/DotGlobe.jsx +289 -0
  27. package/dist/ApexUI-Kit/templates/ElasticSlider/ElasticSlider.jsx +152 -0
  28. package/dist/ApexUI-Kit/templates/ExtrudedText/ExtrudedText.jsx +78 -0
  29. package/dist/ApexUI-Kit/templates/FaqAccordion/FaqAccordion.jsx +88 -0
  30. package/dist/ApexUI-Kit/templates/FlipBook/FlipBook.jsx +191 -0
  31. package/dist/ApexUI-Kit/templates/FlipClock/FlipClock.jsx +121 -0
  32. package/dist/ApexUI-Kit/templates/FloatingNavbar/FloatingNavbar.jsx +162 -0
  33. package/dist/ApexUI-Kit/templates/Folder/Folder.jsx +101 -0
  34. package/dist/ApexUI-Kit/templates/GlitchText/GlitchText.jsx +56 -0
  35. package/dist/ApexUI-Kit/templates/GooeyNav/GooeyNav.jsx +310 -0
  36. package/dist/ApexUI-Kit/templates/HoldToConfirm/HoldToConfirm.jsx +169 -0
  37. package/dist/ApexUI-Kit/templates/HoldToConfirm/UndoNotice.jsx +58 -0
  38. package/dist/ApexUI-Kit/templates/HoloCard/HoloCard.jsx +92 -0
  39. package/dist/ApexUI-Kit/templates/ImageCompare/ImageCompare.jsx +94 -0
  40. package/dist/ApexUI-Kit/templates/IsoStack/IsoStack.jsx +86 -0
  41. package/dist/ApexUI-Kit/templates/LiquidGradient/LiquidGradient.jsx +238 -0
  42. package/dist/ApexUI-Kit/templates/LoadingScreen/LoadingScreen.jsx +805 -0
  43. package/dist/ApexUI-Kit/templates/MagnetLines/MagnetLines.jsx +67 -0
  44. package/dist/ApexUI-Kit/templates/MagneticCursor/MagneticCursor.jsx +148 -0
  45. package/dist/ApexUI-Kit/templates/PixelTransition/PixelTransition.jsx +75 -0
  46. package/dist/ApexUI-Kit/templates/PricingCards/PricingCards.jsx +115 -0
  47. package/dist/ApexUI-Kit/templates/ProfileCard/ProfileCard.jsx +213 -0
  48. package/dist/ApexUI-Kit/templates/ProximityText/ProximityText.jsx +66 -0
  49. package/dist/ApexUI-Kit/templates/RotatingText/RotatingText.jsx +50 -0
  50. package/dist/ApexUI-Kit/templates/ShinyText/ShinyText.jsx +30 -0
  51. package/dist/ApexUI-Kit/templates/SidebarNav/SidebarNav.jsx +220 -0
  52. package/dist/ApexUI-Kit/templates/SpotlightGrid/SpotlightGrid.jsx +110 -0
  53. package/dist/ApexUI-Kit/templates/StarBorder/StarBorder.jsx +33 -0
  54. package/dist/ApexUI-Kit/templates/Stepper/Stepper.jsx +129 -0
  55. package/dist/ApexUI-Kit/templates/SwipeCards/SwipeCards.jsx +123 -0
  56. package/dist/ApexUI-Kit/templates/TextRing3D/TextRing3D.jsx +73 -0
  57. package/dist/ApexUI-Kit/templates/ToastStack/ToastStack.jsx +108 -0
  58. package/dist/ApexUI-Kit/templates/ToolTip/ToolTip.jsx +5 -3
  59. package/dist/ApexUI-Kit/templates/TrueFocus/TrueFocus.jsx +64 -0
  60. package/dist/ApexUI-Kit/templates/TypewriterText/TypewriterText.jsx +70 -0
  61. package/dist/ApexUI-Kit/templates/WebGLCarousel/WebGLCarousel.jsx +326 -0
  62. package/dist/ApexUI-Kit/templates/WorldMap/WorldMap.jsx +359 -0
  63. package/dist/cli/index.js +63 -0
  64. package/package.json +1 -1
@@ -0,0 +1,162 @@
1
+ import React, { useEffect, useId, useState } from "react";
2
+ import { AnimatePresence, motion } from "framer-motion";
3
+ import { ArrowUpRight } from "lucide-react";
4
+
5
+ /*
6
+ * Floating Navbar: a glassy pill that floats over the page. A highlight
7
+ * follows the pointer across the links, the active link keeps a dot, and the
8
+ * bar tucks away when you scroll down and slides back when you scroll up.
9
+ * It switches to a hamburger menu based on its own width (container query),
10
+ * so it is responsive wherever you put it.
11
+ */
12
+
13
+ const DEFAULT_LINKS = [
14
+ { label: "Home", href: "#home" },
15
+ { label: "Components", href: "#components" },
16
+ { label: "Templates", href: "#templates" },
17
+ { label: "Pricing", href: "#pricing" },
18
+ { label: "Blog", href: "#blog" },
19
+ ];
20
+
21
+ function Burger({ open }) {
22
+ return (
23
+ <span className="relative block h-3.5 w-5" aria-hidden="true">
24
+ <motion.span className="absolute left-0 top-0 h-[2px] w-full rounded-full bg-current" animate={open ? { top: "50%", rotate: 45, y: "-50%" } : { top: "0%", rotate: 0, y: 0 }} />
25
+ <motion.span className="absolute left-0 top-1/2 h-[2px] w-full -translate-y-1/2 rounded-full bg-current" animate={{ opacity: open ? 0 : 1, scaleX: open ? 0.3 : 1 }} />
26
+ <motion.span className="absolute bottom-0 left-0 h-[2px] w-full rounded-full bg-current" animate={open ? { bottom: "50%", rotate: -45, y: "50%" } : { bottom: "0%", rotate: 0, y: 0 }} />
27
+ </span>
28
+ );
29
+ }
30
+
31
+ const FloatingNavbar = ({
32
+ brand = "Apex",
33
+ links = DEFAULT_LINKS,
34
+ cta = { label: "Get started", href: "#start" },
35
+ active: activeProp,
36
+ onNavigate,
37
+ hideOnScroll = true,
38
+ scrollRoot,
39
+ accentColor = "#a3e635",
40
+ theme = "dark",
41
+ className = "",
42
+ }) => {
43
+ const id = useId();
44
+ const [active, setActive] = useState(activeProp ?? links[0]?.label);
45
+ const [hover, setHover] = useState(null);
46
+ const [open, setOpen] = useState(false);
47
+ const [hidden, setHidden] = useState(false);
48
+ const [compact, setCompact] = useState(false);
49
+ const dark = theme !== "light";
50
+
51
+ // Hide on scroll down, show on scroll up; compact once the page has moved.
52
+ useEffect(() => {
53
+ const root = scrollRoot?.current ?? window;
54
+ const read = () => (root === window ? window.scrollY : root.scrollTop);
55
+ let last = read();
56
+ const onScroll = () => {
57
+ const y = read();
58
+ setCompact(y > 24);
59
+ if (hideOnScroll && Math.abs(y - last) > 6) setHidden(y > last && y > 80);
60
+ last = y;
61
+ };
62
+ root.addEventListener("scroll", onScroll, { passive: true });
63
+ return () => root.removeEventListener("scroll", onScroll);
64
+ }, [hideOnScroll, scrollRoot]);
65
+
66
+ const go = (link, e) => {
67
+ setActive(link.label);
68
+ setOpen(false);
69
+ onNavigate?.(link, e);
70
+ };
71
+
72
+ const surface = dark ? "border-white/10 bg-neutral-900/70 text-white shadow-[0_10px_40px_-12px_rgba(0,0,0,0.7)]" : "border-black/10 bg-white/75 text-neutral-900 shadow-[0_10px_40px_-14px_rgba(0,0,0,0.25)]";
73
+ const muted = dark ? "text-white/65 hover:text-white" : "text-neutral-500 hover:text-neutral-900";
74
+
75
+ return (
76
+ <div className={`@container sticky top-3 z-50 w-full px-3 ${className}`}>
77
+ <motion.nav
78
+ aria-label="Main"
79
+ initial={false}
80
+ animate={{ y: hidden && !open ? "-140%" : "0%", opacity: hidden && !open ? 0 : 1 }}
81
+ transition={{ type: "spring", stiffness: 380, damping: 34 }}
82
+ className={`mx-auto max-w-4xl rounded-[1.6rem] border backdrop-blur-xl transition-[padding] duration-300 ${surface} ${compact ? "px-2.5 py-1.5" : "px-3 py-2.5"}`}
83
+ >
84
+ <div className="flex items-center justify-between gap-3">
85
+ <a href="#home" onClick={(e) => go(links[0], e)} className="flex items-center gap-2 pl-1.5 text-[0.95rem] font-bold">
86
+ <motion.span whileHover={{ rotate: -12, scale: 1.08 }} className="grid h-8 w-8 place-items-center rounded-xl text-sm font-black text-black" style={{ background: accentColor }}>
87
+ {brand[0]}
88
+ </motion.span>
89
+ {brand}
90
+ </a>
91
+
92
+ {/* Desktop links with a pill that follows the pointer. */}
93
+ <ul className="hidden items-center gap-0.5 @xl:flex" onPointerLeave={() => setHover(null)}>
94
+ {links.map((link) => {
95
+ const on = active === link.label;
96
+ return (
97
+ <li key={link.label} className="relative">
98
+ <a
99
+ href={link.href}
100
+ onClick={(e) => go(link, e)}
101
+ onPointerEnter={() => setHover(link.label)}
102
+ aria-current={on ? "page" : undefined}
103
+ className={`relative block rounded-full px-3 py-2 text-[0.86rem] font-medium transition-colors ${on ? "" : muted}`}
104
+ >
105
+ {hover === link.label && <motion.span layoutId={`${id}-hover`} className={`absolute inset-0 rounded-full ${dark ? "bg-white/[0.08]" : "bg-black/[0.05]"}`} transition={{ type: "spring", stiffness: 500, damping: 38 }} />}
106
+ <span className="relative">{link.label}</span>
107
+ {on && <motion.span layoutId={`${id}-dot`} className="absolute bottom-0.5 left-1/2 h-1 w-1 -translate-x-1/2 rounded-full" style={{ background: accentColor }} />}
108
+ </a>
109
+ </li>
110
+ );
111
+ })}
112
+ </ul>
113
+
114
+ <div className="flex items-center gap-1.5">
115
+ {cta && (
116
+ <motion.a
117
+ href={cta.href}
118
+ whileHover={{ scale: 1.04 }}
119
+ whileTap={{ scale: 0.96 }}
120
+ className="hidden items-center gap-1 rounded-full px-4 py-2 text-[0.85rem] font-semibold text-black @3xl:inline-flex"
121
+ style={{ background: accentColor }}
122
+ >
123
+ {cta.label}
124
+ <ArrowUpRight className="h-4 w-4" />
125
+ </motion.a>
126
+ )}
127
+ <button type="button" aria-label={open ? "Close menu" : "Open menu"} aria-expanded={open} onClick={() => setOpen((o) => !o)} className={`grid h-10 w-10 place-items-center rounded-full @xl:hidden ${dark ? "hover:bg-white/10" : "hover:bg-black/5"}`}>
128
+ <Burger open={open} />
129
+ </button>
130
+ </div>
131
+ </div>
132
+
133
+ {/* Mobile menu */}
134
+ <AnimatePresence initial={false}>
135
+ {open && (
136
+ <motion.div initial={{ height: 0, opacity: 0 }} animate={{ height: "auto", opacity: 1 }} exit={{ height: 0, opacity: 0 }} transition={{ duration: 0.3, ease: [0.22, 1, 0.36, 1] }} className="overflow-hidden @xl:hidden">
137
+ <ul className="space-y-1 px-1 pb-2 pt-3">
138
+ {links.map((link, i) => (
139
+ <motion.li key={link.label} initial={{ x: -14, opacity: 0 }} animate={{ x: 0, opacity: 1 }} transition={{ delay: 0.04 * i + 0.05 }}>
140
+ <a href={link.href} onClick={(e) => go(link, e)} className={`flex items-center justify-between rounded-2xl px-4 py-3 text-[0.95rem] font-medium ${active === link.label ? (dark ? "bg-white/[0.08]" : "bg-black/[0.05]") : ""}`}>
141
+ {link.label}
142
+ {active === link.label && <span className="h-1.5 w-1.5 rounded-full" style={{ background: accentColor }} />}
143
+ </a>
144
+ </motion.li>
145
+ ))}
146
+ {cta && (
147
+ <motion.li initial={{ y: 8, opacity: 0 }} animate={{ y: 0, opacity: 1 }} transition={{ delay: 0.04 * links.length + 0.08 }} className="pt-2">
148
+ <a href={cta.href} className="flex items-center justify-center gap-1 rounded-2xl py-3 text-[0.95rem] font-semibold text-black" style={{ background: accentColor }}>
149
+ {cta.label} <ArrowUpRight className="h-4 w-4" />
150
+ </a>
151
+ </motion.li>
152
+ )}
153
+ </ul>
154
+ </motion.div>
155
+ )}
156
+ </AnimatePresence>
157
+ </motion.nav>
158
+ </div>
159
+ );
160
+ };
161
+
162
+ export default FloatingNavbar;
@@ -0,0 +1,101 @@
1
+ import React, { useState } from "react";
2
+ import { motion } from "framer-motion";
3
+
4
+ /*
5
+ * Folder: a little folder that opens on click — the front flap tilts down
6
+ * and up to three papers fan out of it. While open, each paper leans toward
7
+ * the pointer a little. Click again to tuck everything back in.
8
+ */
9
+
10
+ const DEFAULT_ITEMS = [
11
+ <div key="a" className="space-y-1.5 p-2">
12
+ <p className="text-[0.55rem] font-bold text-neutral-800">Notes.md</p>
13
+ {[85, 60, 72].map((w) => (
14
+ <span key={w} className="block h-1 rounded-full bg-neutral-300" style={{ width: `${w}%` }} />
15
+ ))}
16
+ </div>,
17
+ <div key="b" className="space-y-1.5 p-2">
18
+ <p className="text-[0.55rem] font-bold text-neutral-800">Plan.pdf</p>
19
+ {[70, 90, 50].map((w) => (
20
+ <span key={w} className="block h-1 rounded-full bg-neutral-300" style={{ width: `${w}%` }} />
21
+ ))}
22
+ </div>,
23
+ <div key="c" className="space-y-1.5 p-2">
24
+ <p className="text-[0.55rem] font-bold text-neutral-800">Logo.png</p>
25
+ <span className="block h-7 rounded bg-gradient-to-br from-lime-300 to-cyan-300" />
26
+ </div>,
27
+ ];
28
+
29
+ // Where each paper lands when open, in em (scales with `size`).
30
+ const SPOTS = [
31
+ { x: -3.9, y: -3.3, r: -14 },
32
+ { x: 0, y: -4.2, r: 3 },
33
+ { x: 3.9, y: -3.3, r: 15 },
34
+ ];
35
+
36
+ const Folder = ({ color = "#a3e635", size = 1, items = DEFAULT_ITEMS, label = "Projects", className = "" }) => {
37
+ const [open, setOpen] = useState(false);
38
+ const [lean, setLean] = useState([]);
39
+ const papers = items.slice(0, 3);
40
+ const back = `color-mix(in srgb, ${color} 75%, #000)`;
41
+
42
+ return (
43
+ <div className={`flex flex-col items-center gap-4 ${className}`} style={{ fontSize: `${size}rem` }}>
44
+ <button
45
+ type="button"
46
+ aria-expanded={open}
47
+ aria-label={`${label} folder`}
48
+ onClick={() => {
49
+ setOpen((o) => !o);
50
+ setLean([]);
51
+ }}
52
+ className="group relative h-[5.5em] w-[7em] outline-none transition-transform duration-200 hover:-translate-y-1"
53
+ style={{ perspective: "600px" }}
54
+ >
55
+ {/* Back panel with tab */}
56
+ <span className="absolute inset-0 rounded-[0.6em] rounded-tl-none" style={{ background: back }} />
57
+ <span className="absolute -top-[0.7em] left-0 h-[0.9em] w-[2.6em] rounded-t-[0.45em]" style={{ background: back }} />
58
+
59
+ {papers.map((p, i) => {
60
+ const spot = SPOTS[papers.length === 1 ? 1 : papers.length === 2 ? i * 2 : i];
61
+ const l = lean[i] ?? { x: 0, y: 0 };
62
+ return (
63
+ <motion.span
64
+ key={i}
65
+ className="absolute bottom-[10%] left-[10%] h-[80%] w-[80%] overflow-hidden rounded-[0.4em] bg-white text-left shadow-md"
66
+ initial={false}
67
+ animate={open ? { x: `${spot.x + l.x}em`, y: `${spot.y + l.y}em`, rotate: spot.r, scale: 1 } : { x: "0em", y: `${-0.3 - i * 0.2}em`, rotate: 0, scale: 0.92 - (2 - i) * 0.03 }}
68
+ transition={{ type: "spring", stiffness: 300, damping: 22, delay: open ? i * 0.04 : 0 }}
69
+ onPointerMove={(e) => {
70
+ if (!open) return;
71
+ const r = e.currentTarget.getBoundingClientRect();
72
+ const next = [...lean];
73
+ next[i] = { x: ((e.clientX - (r.left + r.width / 2)) / r.width) * 0.8, y: ((e.clientY - (r.top + r.height / 2)) / r.height) * 0.8 };
74
+ setLean(next);
75
+ }}
76
+ onPointerLeave={() => {
77
+ const next = [...lean];
78
+ next[i] = { x: 0, y: 0 };
79
+ setLean(next);
80
+ }}
81
+ >
82
+ {p}
83
+ </motion.span>
84
+ );
85
+ })}
86
+
87
+ {/* Front flap */}
88
+ <motion.span
89
+ className="absolute inset-x-0 bottom-0 h-[85%] origin-bottom rounded-[0.6em] shadow-[0_-4px_12px_rgba(0,0,0,0.12)]"
90
+ style={{ background: `linear-gradient(180deg, color-mix(in srgb, ${color} 85%, #fff), ${color})` }}
91
+ initial={false}
92
+ animate={{ rotateX: open ? -38 : 0 }}
93
+ transition={{ type: "spring", stiffness: 260, damping: 20 }}
94
+ />
95
+ </button>
96
+ <span className="text-sm font-medium opacity-70">{label}</span>
97
+ </div>
98
+ );
99
+ };
100
+
101
+ export default Folder;
@@ -0,0 +1,56 @@
1
+ import React, { useEffect, useRef, useState } from "react";
2
+ import { useReducedMotion } from "framer-motion";
3
+
4
+ /*
5
+ * Glitch Text: two colored copies of the text sit behind it and jump
6
+ * around in random horizontal slices, like a broken signal. Runs all the
7
+ * time or only while hovered.
8
+ */
9
+
10
+ const slice = () => {
11
+ const top = Math.random() * 90;
12
+ return `inset(${top}% 0 ${Math.max(0, 100 - top - (6 + Math.random() * 22))}% 0)`;
13
+ };
14
+
15
+ const GlitchText = ({ text = "GLITCH", colors = ["#22d3ee", "#f43f5e"], speed = 90, hoverOnly = false, className = "" }) => {
16
+ const reduced = useReducedMotion();
17
+ const [hover, setHover] = useState(false);
18
+ const a = useRef(null);
19
+ const b = useRef(null);
20
+ const active = !reduced && (!hoverOnly || hover);
21
+
22
+ useEffect(() => {
23
+ const layers = [a.current, b.current];
24
+ if (!active) {
25
+ layers.forEach((el) => {
26
+ el.style.clipPath = "inset(50% 0 50% 0)";
27
+ el.style.transform = "none";
28
+ });
29
+ return undefined;
30
+ }
31
+ const id = setInterval(() => {
32
+ layers.forEach((el, i) => {
33
+ const burst = Math.random() < 0.25;
34
+ el.style.clipPath = slice();
35
+ el.style.transform = `translate(${(i ? 1 : -1) * (burst ? 6 + Math.random() * 6 : 1 + Math.random() * 3)}px, ${(Math.random() - 0.5) * 2}px)`;
36
+ });
37
+ }, speed);
38
+ return () => clearInterval(id);
39
+ }, [active, speed]);
40
+
41
+ const layer = "pointer-events-none absolute inset-0 select-none";
42
+ return (
43
+ <span className={`relative inline-block font-black tracking-tight ${className}`} onPointerEnter={() => setHover(true)} onPointerLeave={() => setHover(false)}>
44
+ <span>{text}</span>
45
+ {/* Sliced, shifted copies painted over the text. */}
46
+ <span ref={a} aria-hidden="true" className={layer} style={{ color: colors[0], clipPath: "inset(50% 0 50% 0)" }}>
47
+ {text}
48
+ </span>
49
+ <span ref={b} aria-hidden="true" className={layer} style={{ color: colors[1], clipPath: "inset(50% 0 50% 0)" }}>
50
+ {text}
51
+ </span>
52
+ </span>
53
+ );
54
+ };
55
+
56
+ export default GlitchText;
@@ -0,0 +1,310 @@
1
+ import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
2
+
3
+ /*
4
+ * Gooey Nav: the active pill is drawn in WebGL as a metaball. A fragment
5
+ * shader smooth-unions a capsule (the pill) with a handful of droplets, so
6
+ * they genuinely melt into each other. When you switch links the pill's
7
+ * leading edge races ahead and the trailing edge catches up (it stretches,
8
+ * thins, then snaps back) and droplets burst out and get pulled back in.
9
+ * Labels are plain HTML on top, so they stay crisp and accessible. Falls
10
+ * back to a CSS pill without WebGL.
11
+ */
12
+
13
+ const DEFAULT_ITEMS = [
14
+ { label: "Home", href: "#" },
15
+ { label: "Components", href: "#" },
16
+ { label: "Templates", href: "#" },
17
+ { label: "Docs", href: "#" },
18
+ ];
19
+
20
+ const MAX_DROPS = 14;
21
+ const PAD = 22; // canvas bleed around the bar, for drops and glow (px)
22
+
23
+ const VERT = `
24
+ attribute vec2 aPos;
25
+ void main() { gl_Position = vec4(aPos, 0.0, 1.0); }
26
+ `;
27
+
28
+ const FRAG = `
29
+ precision highp float;
30
+ uniform vec2 uRes;
31
+ uniform float uDpr;
32
+ uniform vec4 uPill;
33
+ uniform vec4 uDrops[${MAX_DROPS}];
34
+ uniform vec3 uColor;
35
+ uniform float uGoo;
36
+ uniform float uGlow;
37
+
38
+ float sdCapsule(vec2 p, vec2 halfSize) {
39
+ float r = min(halfSize.x, halfSize.y);
40
+ vec2 q = abs(p) - halfSize + r;
41
+ return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - r;
42
+ }
43
+
44
+ float smin(float a, float b, float k) {
45
+ float h = clamp(0.5 + 0.5 * (b - a) / k, 0.0, 1.0);
46
+ return mix(b, a, h) - k * h * (1.0 - h);
47
+ }
48
+
49
+ void main() {
50
+ // Pixel position in CSS px, origin top-left.
51
+ vec2 p = vec2(gl_FragCoord.x, uRes.y - gl_FragCoord.y) / uDpr;
52
+ vec2 c = uPill.xy + uPill.zw * 0.5;
53
+ float d = sdCapsule(p - c, uPill.zw * 0.5);
54
+ for (int i = 0; i < ${MAX_DROPS}; i++) {
55
+ vec4 dr = uDrops[i];
56
+ if (dr.z > 0.01) d = smin(d, length(p - dr.xy) - dr.z, uGoo);
57
+ }
58
+ float aa = 0.8;
59
+ float body = 1.0 - smoothstep(-aa, aa, d);
60
+ // Soft top highlight and darker bottom for a bit of volume.
61
+ float v = clamp((p.y - uPill.y) / max(uPill.w, 1.0), 0.0, 1.0);
62
+ vec3 col = uColor * (1.08 - 0.18 * v);
63
+ float rim = smoothstep(-3.0, 0.0, d) * body * 0.12;
64
+ col += rim;
65
+ float glow = exp(-max(d, 0.0) / 5.0) * uGlow * (1.0 - body);
66
+ float a = body + glow;
67
+ gl_FragColor = vec4(col * a, a);
68
+ }
69
+ `;
70
+
71
+ const hexToRgb = (hex) => {
72
+ let h = String(hex).replace("#", "");
73
+ if (h.length === 3) h = h.split("").map((c) => c + c).join("");
74
+ const n = parseInt(h.slice(0, 6), 16);
75
+ if (Number.isNaN(n)) return [0.64, 0.9, 0.21];
76
+ return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255];
77
+ };
78
+
79
+ function createGL(canvas) {
80
+ const gl = canvas.getContext("webgl", { premultipliedAlpha: true, antialias: false, alpha: true });
81
+ if (!gl) return null;
82
+ const compile = (type, src) => {
83
+ const s = gl.createShader(type);
84
+ gl.shaderSource(s, src);
85
+ gl.compileShader(s);
86
+ return gl.getShaderParameter(s, gl.COMPILE_STATUS) ? s : null;
87
+ };
88
+ const vs = compile(gl.VERTEX_SHADER, VERT);
89
+ const fs = compile(gl.FRAGMENT_SHADER, FRAG);
90
+ if (!vs || !fs) return null;
91
+ const prog = gl.createProgram();
92
+ gl.attachShader(prog, vs);
93
+ gl.attachShader(prog, fs);
94
+ gl.linkProgram(prog);
95
+ if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) return null;
96
+ gl.useProgram(prog);
97
+ const buf = gl.createBuffer();
98
+ gl.bindBuffer(gl.ARRAY_BUFFER, buf);
99
+ gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
100
+ const loc = gl.getAttribLocation(prog, "aPos");
101
+ gl.enableVertexAttribArray(loc);
102
+ gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
103
+ const u = (n) => gl.getUniformLocation(prog, n);
104
+ return { gl, uRes: u("uRes"), uDpr: u("uDpr"), uPill: u("uPill"), uDrops: u("uDrops"), uColor: u("uColor"), uGoo: u("uGoo"), uGlow: u("uGlow") };
105
+ }
106
+
107
+ // One spring integration step.
108
+ const step = (s, target, k, c, dt) => {
109
+ const a = k * (target - s.x) - c * s.v;
110
+ s.v += a * dt;
111
+ s.x += s.v * dt;
112
+ };
113
+
114
+ const GooeyNav = ({ items = DEFAULT_ITEMS, initialActive = 0, onChange, accentColor = "#a3e635", particleCount = 8, goo = 12, theme = "dark", className = "" }) => {
115
+ const [active, setActive] = useState(initialActive);
116
+ const [fallback, setFallback] = useState(false);
117
+ const bar = useRef(null);
118
+ const canvas = useRef(null);
119
+ const fallbackPill = useRef(null);
120
+ const links = useRef([]);
121
+ const sim = useRef(null);
122
+ const color = useRef(hexToRgb(accentColor));
123
+ const gooRef = useRef(goo);
124
+ const glowRef = useRef(theme === "light" ? 0 : 0.22);
125
+ const dark = theme !== "light";
126
+
127
+ useEffect(() => {
128
+ color.current = hexToRgb(accentColor);
129
+ gooRef.current = goo;
130
+ glowRef.current = theme === "light" ? 0 : 0.22;
131
+ if (sim.current) sim.current.dirty = true;
132
+ }, [accentColor, goo, theme]);
133
+
134
+ const measure = (i) => {
135
+ const el = links.current[i];
136
+ const b = bar.current;
137
+ if (!el || !b) return null;
138
+ const a = el.getBoundingClientRect();
139
+ const p = b.getBoundingClientRect();
140
+ return { l: a.left - p.left + PAD, r: a.right - p.left + PAD, t: a.top - p.top + PAD, h: a.height };
141
+ };
142
+
143
+ // Set up GL + simulation once.
144
+ useLayoutEffect(() => {
145
+ const cv = canvas.current;
146
+ const ctx = createGL(cv);
147
+ if (!ctx) setFallback(true);
148
+ const m = measure(active) ?? { l: 0, r: 0, t: 0, h: 0 };
149
+ const s = {
150
+ l: { x: m.l, v: 0 },
151
+ r: { x: m.r, v: 0 },
152
+ t: { x: m.t, v: 0 },
153
+ h: { x: m.h, v: 0 },
154
+ target: m,
155
+ activeIndex: active,
156
+ dir: 1,
157
+ drops: [],
158
+ dirty: true,
159
+ dpr: 1,
160
+ };
161
+ sim.current = s;
162
+
163
+ const resize = () => {
164
+ const r = bar.current.getBoundingClientRect();
165
+ s.dpr = Math.min(window.devicePixelRatio || 1, 2);
166
+ const w = r.width + PAD * 2;
167
+ const h = r.height + PAD * 2;
168
+ cv.width = Math.round(w * s.dpr);
169
+ cv.height = Math.round(h * s.dpr);
170
+ cv.style.width = `${w}px`;
171
+ cv.style.height = `${h}px`;
172
+ const t = measure(s.activeIndex);
173
+ if (t) {
174
+ s.target = t;
175
+ s.l.x = t.l;
176
+ s.r.x = t.r;
177
+ s.t.x = t.t;
178
+ s.h.x = t.h;
179
+ s.l.v = s.r.v = s.t.v = s.h.v = 0;
180
+ }
181
+ s.dirty = true;
182
+ };
183
+ resize();
184
+ const ro = new ResizeObserver(resize);
185
+ ro.observe(bar.current);
186
+
187
+ const dropData = new Float32Array(MAX_DROPS * 4);
188
+ let raf = 0;
189
+ let last = performance.now();
190
+ const frame = (now) => {
191
+ const dt = Math.min(0.033, (now - last) / 1000);
192
+ last = now;
193
+ const T = s.target;
194
+ // Leading edge is stiff, trailing edge is loose → stretch.
195
+ const lead = [520, 30];
196
+ const trail = [170, 17];
197
+ const [kl, cl] = s.dir > 0 ? trail : lead;
198
+ const [kr, cr] = s.dir > 0 ? lead : trail;
199
+ step(s.l, T.l, kl, cl, dt);
200
+ step(s.r, T.r, kr, cr, dt);
201
+ step(s.t, T.t, 400, 32, dt);
202
+ step(s.h, T.h, 400, 32, dt);
203
+
204
+ // Thin the pill while it is stretched (keeps the "volume").
205
+ const w = s.r.x - s.l.x;
206
+ const restW = T.r - T.l || 1;
207
+ const stretch = Math.max(0, w / restW - 1);
208
+ const h = s.h.x * (1 - Math.min(0.32, stretch * 0.35));
209
+ const top = s.t.x + (s.h.x - h) / 2;
210
+
211
+ // Droplets fly out, then a spring pulls them back to the pill centre.
212
+ const cx = (s.l.x + s.r.x) / 2;
213
+ const cy = top + h / 2;
214
+ s.drops = s.drops.filter((d) => (d.life -= dt) > 0);
215
+ for (const d of s.drops) {
216
+ d.vx += ((cx - d.x) * d.k - d.vx * 5) * dt;
217
+ d.vy += ((cy - d.y) * d.k - d.vy * 5) * dt;
218
+ d.x += d.vx * dt;
219
+ d.y += d.vy * dt;
220
+ }
221
+
222
+ const moving = s.drops.length > 0 || [s.l, s.r, s.t, s.h].some((q) => Math.abs(q.v) > 0.5) || Math.abs(s.l.x - T.l) > 0.3 || Math.abs(s.r.x - T.r) > 0.3;
223
+ if (moving || s.dirty) {
224
+ s.dirty = false;
225
+ if (ctx) {
226
+ const { gl } = ctx;
227
+ gl.viewport(0, 0, cv.width, cv.height);
228
+ gl.clearColor(0, 0, 0, 0);
229
+ gl.clear(gl.COLOR_BUFFER_BIT);
230
+ gl.uniform2f(ctx.uRes, cv.width, cv.height);
231
+ gl.uniform1f(ctx.uDpr, s.dpr);
232
+ gl.uniform4f(ctx.uPill, s.l.x, top, Math.max(1, w), Math.max(1, h));
233
+ dropData.fill(0);
234
+ s.drops.slice(0, MAX_DROPS).forEach((d, i) => {
235
+ const life = Math.max(0, d.life / d.max);
236
+ dropData.set([d.x, d.y, d.r * Math.min(1, life * 1.6), 1], i * 4);
237
+ });
238
+ gl.uniform4fv(ctx.uDrops, dropData);
239
+ gl.uniform3fv(ctx.uColor, color.current);
240
+ gl.uniform1f(ctx.uGoo, gooRef.current);
241
+ gl.uniform1f(ctx.uGlow, glowRef.current);
242
+ gl.drawArrays(gl.TRIANGLES, 0, 3);
243
+ } else if (fallbackPill.current) {
244
+ Object.assign(fallbackPill.current.style, { left: `${s.l.x - PAD}px`, top: `${top - PAD}px`, width: `${w}px`, height: `${h}px` });
245
+ }
246
+ }
247
+ raf = requestAnimationFrame(frame);
248
+ };
249
+ raf = requestAnimationFrame(frame);
250
+ return () => {
251
+ cancelAnimationFrame(raf);
252
+ ro.disconnect();
253
+ ctx?.gl.getExtension("WEBGL_lose_context")?.loseContext();
254
+ };
255
+ // Built once per item count; later selections go through sim.current.
256
+ // eslint-disable-next-line react-hooks/exhaustive-deps
257
+ }, [items.length]);
258
+
259
+ const select = (i, e) => {
260
+ e?.preventDefault();
261
+ const s = sim.current;
262
+ if (i === active || !s) return;
263
+ const t = measure(i);
264
+ if (!t) return;
265
+ s.dir = i > active ? 1 : -1;
266
+ s.target = t;
267
+ s.activeIndex = i;
268
+ setActive(i);
269
+ onChange?.(i, items[i]);
270
+ if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) {
271
+ s.l.x = t.l;
272
+ s.r.x = t.r;
273
+ s.dirty = true;
274
+ return;
275
+ }
276
+ const cx = (t.l + t.r) / 2;
277
+ const cy = t.t + t.h / 2;
278
+ // Burst at the destination; the droplets get pulled back into the pill.
279
+ for (let k = 0; k < particleCount; k++) {
280
+ const a = (Math.PI * 2 * k) / particleCount + (Math.random() - 0.5) * 0.6;
281
+ const sp = 120 + Math.random() * 110;
282
+ const max = 0.75 + Math.random() * 0.3;
283
+ s.drops.push({ x: cx, y: cy, vx: Math.cos(a) * sp * 1.5, vy: Math.sin(a) * sp * 0.55, r: 4 + Math.random() * 4, k: 55, life: max, max });
284
+ }
285
+ if (s.drops.length > MAX_DROPS) s.drops.splice(0, s.drops.length - MAX_DROPS);
286
+ };
287
+
288
+ return (
289
+ <nav className={`relative inline-flex max-w-full ${className}`} aria-label="Main">
290
+ <div ref={bar} className={`relative flex items-center gap-0.5 rounded-full p-1.5 ${dark ? "bg-neutral-950 ring-1 ring-white/10" : "bg-white shadow-sm ring-1 ring-black/10"}`}>
291
+ <canvas ref={canvas} aria-hidden="true" className="pointer-events-none absolute" style={{ left: -PAD, top: -PAD, display: fallback ? "none" : "block" }} />
292
+ {fallback && <span ref={fallbackPill} aria-hidden="true" className="pointer-events-none absolute rounded-full" style={{ background: accentColor }} />}
293
+ {items.map((item, i) => (
294
+ <a
295
+ key={i}
296
+ ref={(el) => (links.current[i] = el)}
297
+ href={item.href}
298
+ onClick={(e) => select(i, e)}
299
+ aria-current={i === active ? "page" : undefined}
300
+ className={`relative z-[1] shrink-0 rounded-full px-3.5 py-2.5 text-[0.95rem] font-medium outline-none transition-colors duration-300 focus-visible:ring-2 focus-visible:ring-current/30 sm:px-5 ${i === active ? "text-neutral-950" : dark ? "text-white/65 hover:text-white" : "text-neutral-500 hover:text-neutral-900"}`}
301
+ >
302
+ {item.label}
303
+ </a>
304
+ ))}
305
+ </div>
306
+ </nav>
307
+ );
308
+ };
309
+
310
+ export default GooeyNav;