apex-ui-kit 2.7.0 → 2.9.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 (24) hide show
  1. package/ReadMe.md +21 -9
  2. package/dist/ApexUI-Kit/templates/AnimatedTabs/AnimatedTabs.jsx +1 -1
  3. package/dist/ApexUI-Kit/templates/ArcGallery/ArcGallery.jsx +29 -11
  4. package/dist/ApexUI-Kit/templates/CircularCarousel/CircularCarousel.jsx +0 -1
  5. package/dist/ApexUI-Kit/templates/CommandPalette/CommandPalette.jsx +250 -0
  6. package/dist/ApexUI-Kit/templates/CrossedMarquee/CrossedMarquee.jsx +158 -0
  7. package/dist/ApexUI-Kit/templates/DitherWaves/DitherWaves.jsx +207 -0
  8. package/dist/ApexUI-Kit/templates/FlipClock/FlipClock.jsx +3 -2
  9. package/dist/ApexUI-Kit/templates/GlareCard/GlareCard.jsx +25 -18
  10. package/dist/ApexUI-Kit/templates/GlassLens/GlassLens.jsx +273 -0
  11. package/dist/ApexUI-Kit/templates/GooeyNav/GooeyNav.jsx +1 -1
  12. package/dist/ApexUI-Kit/templates/ImageAccordion/ImageAccordion.jsx +116 -0
  13. package/dist/ApexUI-Kit/templates/InfiniteCanvas/InfiniteCanvas.jsx +230 -0
  14. package/dist/ApexUI-Kit/templates/LiquidGlass/LiquidGlass.jsx +444 -0
  15. package/dist/ApexUI-Kit/templates/OrbitIcons/OrbitIcons.jsx +169 -0
  16. package/dist/ApexUI-Kit/templates/RadialMenu/RadialMenu.jsx +20 -4
  17. package/dist/ApexUI-Kit/templates/ScrollStack/ScrollStack.jsx +3 -3
  18. package/dist/ApexUI-Kit/templates/SunriseBackground/SunriseBackground.jsx +68 -2
  19. package/dist/ApexUI-Kit/templates/TextRing3D/TextRing3D.jsx +18 -3
  20. package/dist/ApexUI-Kit/templates/ThemeSwitcher/ThemeSwitcher.jsx +215 -0
  21. package/dist/ApexUI-Kit/templates/ThemeSwitcher/themes.js +38 -0
  22. package/dist/ApexUI-Kit/templates/ToastStack/ToastStack.jsx +21 -13
  23. package/dist/cli/index.js +10 -0
  24. package/package.json +2 -2
package/ReadMe.md CHANGED
@@ -4,7 +4,7 @@
4
4
  [![npm downloads](https://img.shields.io/npm/dm/apex-ui-kit?color=a3e635)](https://www.npmjs.com/package/apex-ui-kit)
5
5
  [![license](https://img.shields.io/npm/l/apex-ui-kit?color=a3e635)](./Licence.md)
6
6
 
7
- **107 free, animated UI components for React.** Pick one, add it with a single command, and the source lands in your project so you can read it and change anything.
7
+ **116 free, animated UI components for React.** Pick one, add it with a single command, and the source lands in your project so you can read it and change anything.
8
8
 
9
9
  Built with React, Tailwind CSS and Framer Motion, plus GSAP, Canvas and WebGL where a component needs them.
10
10
 
@@ -15,7 +15,7 @@ Built with React, Tailwind CSS and Framer Motion, plus GSAP, Canvas and WebGL wh
15
15
 
16
16
  ## Why ApexUI
17
17
 
18
- - **107 components** across backgrounds, cards, text effects, navigation, buttons, interactive UI, AI interfaces, loaders and maps.
18
+ - **116 components** across backgrounds, cards, text effects, navigation, buttons, interactive UI, AI interfaces, loaders and maps.
19
19
  - **You own the code.** The CLI copies plain `.jsx` files into your project, no runtime package to keep in sync.
20
20
  - **Every docs page has** a live preview, a playground to tweak props, a Desktop / Tablet / Mobile preview, the full props table and a ready prompt you can paste into ChatGPT or Claude.
21
21
  - **Smooth on real devices.** Animations stick to transform and opacity, respect reduced motion, and work with mouse, touch and keyboard.
@@ -99,12 +99,13 @@ Every component's props, variants and copy-ready code are on its docs page.
99
99
 
100
100
  ## Components
101
101
 
102
- ### Backgrounds (16)
102
+ ### Backgrounds (17)
103
103
 
104
104
  | Component | CLI name |
105
105
  | --- | --- |
106
106
  | [Aurora Background](https://apex-ui.in/components/aurora-background) | `aurora-background` |
107
107
  | [BluePrint Background](https://apex-ui.in/components/blueprint-background) | `blueprint-background` |
108
+ | [Dither Waves](https://apex-ui.in/components/dither-waves) | `dither-waves` |
108
109
  | [Dot Wave Background](https://apex-ui.in/components/dot-wave-background) | `dot-wave-background` |
109
110
  | [Galaxy 3D](https://apex-ui.in/components/galaxy-3d) | `galaxy-3d` |
110
111
  | [Horizon Glow](https://apex-ui.in/components/horizon-glow) | `horizon-glow` |
@@ -120,7 +121,7 @@ Every component's props, variants and copy-ready code are on its docs page.
120
121
  | [Sunrise Background](https://apex-ui.in/components/sunrise-background) | `sunrise-background` |
121
122
  | [Warp Tunnel](https://apex-ui.in/components/warp-tunnel) | `warp-tunnel` |
122
123
 
123
- ### Cards (19)
124
+ ### Cards (20)
124
125
 
125
126
  | Component | CLI name |
126
127
  | --- | --- |
@@ -133,6 +134,7 @@ Every component's props, variants and copy-ready code are on its docs page.
133
134
  | [Glare Card](https://apex-ui.in/components/glare-card) | `glare-card` |
134
135
  | [Holo Card](https://apex-ui.in/components/holo-card) | `holo-card` |
135
136
  | [Hyper Card](https://apex-ui.in/components/hyper-card) | `hyper-card` |
137
+ | [Image Accordion](https://apex-ui.in/components/image-accordion) | `image-accordion` |
136
138
  | [Iso Stack](https://apex-ui.in/components/iso-stack) | `iso-stack` |
137
139
  | [Parallax Card](https://apex-ui.in/components/parallax-card) | `parallax-card` |
138
140
  | [Pixel Transition](https://apex-ui.in/components/pixel-transition) | `pixel-transition` |
@@ -144,7 +146,7 @@ Every component's props, variants and copy-ready code are on its docs page.
144
146
  | [Swipe Cards](https://apex-ui.in/components/swipe-cards) | `swipe-cards` |
145
147
  | [Testimonials](https://apex-ui.in/components/testimonials) | `testimonials` |
146
148
 
147
- ### Text & Motion (19)
149
+ ### Text & Motion (20)
148
150
 
149
151
  | Component | CLI name |
150
152
  | --- | --- |
@@ -153,6 +155,7 @@ Every component's props, variants and copy-ready code are on its docs page.
153
155
  | [Beam Bar](https://apex-ui.in/components/beam-bar) | `beam-bar` |
154
156
  | [Blur Reveal Text](https://apex-ui.in/components/blur-reveal-text) | `blur-reveal-text` |
155
157
  | [Circular Text](https://apex-ui.in/components/circular-text) | `circular-text` |
158
+ | [Crossed Marquee](https://apex-ui.in/components/crossed-marquee) | `crossed-marquee` |
156
159
  | [Extruded Text](https://apex-ui.in/components/extruded-text) | `extruded-text` |
157
160
  | [Flip Clock](https://apex-ui.in/components/flip-clock) | `flip-clock` |
158
161
  | [Glitch Text](https://apex-ui.in/components/glitch-text) | `glitch-text` |
@@ -168,11 +171,12 @@ Every component's props, variants and copy-ready code are on its docs page.
168
171
  | [True Focus](https://apex-ui.in/components/true-focus) | `true-focus` |
169
172
  | [Typewriter Text](https://apex-ui.in/components/typewriter-text) | `typewriter-text` |
170
173
 
171
- ### Navigation (9)
174
+ ### Navigation (11)
172
175
 
173
176
  | Component | CLI name |
174
177
  | --- | --- |
175
178
  | [Animated Tabs](https://apex-ui.in/components/animated-tabs) | `animated-tabs` |
179
+ | [Command Palette](https://apex-ui.in/components/command-palette) | `command-palette` |
176
180
  | [Floating Navbar](https://apex-ui.in/components/floating-navbar) | `floating-navbar` |
177
181
  | [Gooey Nav](https://apex-ui.in/components/gooey-nav) | `gooey-nav` |
178
182
  | [Magnify Dock](https://apex-ui.in/components/magnify-dock) | `magnify-dock` |
@@ -180,6 +184,7 @@ Every component's props, variants and copy-ready code are on its docs page.
180
184
  | [Nav Menu](https://apex-ui.in/components/nav-menu) | `nav-menu` |
181
185
  | [Radial Menu](https://apex-ui.in/components/radial-menu) | `radial-menu` |
182
186
  | [Sidebar Nav](https://apex-ui.in/components/sidebar-nav) | `sidebar-nav` |
187
+ | [Theme Switcher](https://apex-ui.in/components/theme-switcher) | `theme-switcher` |
183
188
  | [Theme Toggle](https://apex-ui.in/components/theme-toggle) | `theme-toggle` |
184
189
 
185
190
  ### Buttons (4)
@@ -191,7 +196,7 @@ Every component's props, variants and copy-ready code are on its docs page.
191
196
  | [Magnetic Button](https://apex-ui.in/components/magnetic-button) | `magnetic-button` |
192
197
  | [Star Border](https://apex-ui.in/components/star-border) | `star-border` |
193
198
 
194
- ### Interactive (27)
199
+ ### Interactive (31)
195
200
 
196
201
  | Component | CLI name |
197
202
  | --- | --- |
@@ -210,11 +215,15 @@ Every component's props, variants and copy-ready code are on its docs page.
210
215
  | [Elastic Slider](https://apex-ui.in/components/elastic-slider) | `elastic-slider` |
211
216
  | [FAQ Accordion](https://apex-ui.in/components/faq-accordion) | `faq-accordion` |
212
217
  | [Flip Book](https://apex-ui.in/components/flip-book) | `flip-book` |
218
+ | [Glass Lens](https://apex-ui.in/components/glass-lens) | `glass-lens` |
213
219
  | [Image Compare](https://apex-ui.in/components/image-compare) | `image-compare` |
220
+ | [Infinite Canvas](https://apex-ui.in/components/infinite-canvas) | `infinite-canvas` |
214
221
  | [Liquid Blob 3D](https://apex-ui.in/components/liquid-blob-3d) | `liquid-blob-3d` |
222
+ | [Liquid Glass](https://apex-ui.in/components/liquid-glass) | `liquid-glass` |
215
223
  | [Magnet Lines](https://apex-ui.in/components/magnet-lines) | `magnet-lines` |
216
224
  | [Magnetic Cursor](https://apex-ui.in/components/magnetic-cursor) | `magnetic-cursor` |
217
225
  | [Marquee 3D](https://apex-ui.in/components/marquee-3d) | `marquee-3d` |
226
+ | [Orbit Icons](https://apex-ui.in/components/orbit-icons) | `orbit-icons` |
218
227
  | [Particle Morph](https://apex-ui.in/components/particle-morph) | `particle-morph` |
219
228
  | [Pointer Follower](https://apex-ui.in/components/pointer-follower) | `pointer-follower` |
220
229
  | [Receipt Printer](https://apex-ui.in/components/receipt-printer) | `receipt-printer` |
@@ -251,9 +260,12 @@ Every component's props, variants and copy-ready code are on its docs page.
251
260
  | [Dot Globe](https://apex-ui.in/components/dot-globe) | `dot-globe` |
252
261
  | [World Map](https://apex-ui.in/components/world-map) | `world-map` |
253
262
 
254
- ## What's new in 2.7
263
+ ## What's new in 2.9
255
264
 
256
- - 38 new components, including WebGL and three.js (Sunrise Background, Horizon Glow, Circular Carousel, Arc Gallery, Galaxy 3D, Warp Tunnel, Wireframe Knot 3D, Marquee 3D, Distortion Image, Liquid Blob 3D, Particle Morph, Retro Grid 3D), Scroll Stack, Particle Text, Edge Blur, Magnetic Button, Radial Menu, the AI voice family (Wave, Bars, Ring, Blob), Gooey Nav (WebGL metaball pill), Coverflow 3D, Card Swap 3D, 3D Text Ring, Elastic Slider, Stepper and Pixel Transition.
265
+ - New in 2.9: Liquid Glass (iOS style WebGL glass), 10 live page templates on the site, Arc Gallery auto scroll, Scroll Stack on the page scroll, Sunrise trees and birds.
266
+ - New in 2.8: Glass Lens (WebGL liquid glass), Dither Waves (WebGL 1-bit background), Infinite Canvas, Image Accordion, Orbit Icons, Command Palette, Crossed Marquee (tech-stack bands with simple-icons) and Theme Switcher (8 themes with a circle reveal).
267
+ - Toast Stack and Radial Menu no longer blink in production builds; Radial Menu, 3D Text Ring, Animated Tabs and Gooey Nav now fit 320px phones.
268
+ - 38 new components since 2.6, including WebGL and three.js (Sunrise Background, Horizon Glow, Circular Carousel, Arc Gallery, Galaxy 3D, Warp Tunnel, Wireframe Knot 3D, Marquee 3D, Distortion Image, Liquid Blob 3D, Particle Morph, Retro Grid 3D), Scroll Stack, Particle Text, Edge Blur, Magnetic Button, Radial Menu, the AI voice family (Wave, Bars, Ring, Blob), Gooey Nav (WebGL metaball pill), Coverflow 3D, Card Swap 3D, 3D Text Ring, Elastic Slider, Stepper and Pixel Transition.
257
269
  - Accordion Marquee is rebuilt: one liquid band glides between rows on springs, leans toward the pointer, and its marquee speeds up with sideways mouse moves. No GSAP needed any more.
258
270
  - AI Model Prompt now exports `DEFAULT_LLM_MODELS` and an index file, so you can import both from the folder.
259
271
  - Loading Screens follow the real page load by default: drop `<LoadingScreen fullScreen />` into your app and it covers the page while it loads, with a live bar and percentage, then hides itself. Pass `progress` to drive it yourself.
@@ -58,7 +58,7 @@ const AnimatedTabs = ({ tabs = DEFAULT_TABS, defaultTab, variant = "pill", accen
58
58
  aria-controls={`${id}-panel`}
59
59
  tabIndex={on ? 0 : -1}
60
60
  onClick={() => select(i)}
61
- className={`relative flex-1 whitespace-nowrap px-3 py-2 text-[0.82rem] sm:px-4 sm:text-[0.88rem] font-medium outline-none transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-current/40 ${variant === "underline" ? "pb-3" : variant === "pill" ? "rounded-full" : "rounded-lg"} ${on ? (variant === "pill" ? "text-neutral-950" : "") : dark ? "text-white/55 hover:text-white" : "text-neutral-500 hover:text-neutral-900"}`}
61
+ className={`relative flex-1 whitespace-nowrap px-3 py-2 text-[0.82rem] max-[380px]:px-2 max-[380px]:text-[0.76rem] sm:px-4 sm:text-[0.88rem] font-medium outline-none transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-current/40 ${variant === "underline" ? "pb-3" : variant === "pill" ? "rounded-full" : "rounded-lg"} ${on ? (variant === "pill" ? "text-neutral-950" : "") : dark ? "text-white/55 hover:text-white" : "text-neutral-500 hover:text-neutral-900"}`}
62
62
  >
63
63
  {on && (
64
64
  <motion.span
@@ -3,9 +3,10 @@ import { Mesh, OrthographicCamera, PlaneGeometry, Scene, ShaderMaterial, SRGBCol
3
3
 
4
4
  /*
5
5
  * Arc Gallery: an endless row of image cards laid along a gentle arc and
6
- * drawn in WebGL. Drag, flick or scroll the wheel to move it; it eases to a
7
- * stop and settles on a card. While it moves the cards ripple slightly,
8
- * the edges fade out, and a title sits under each card. Arrow keys step.
6
+ * drawn in WebGL. It drifts on its own (pausing on hover); drag, flick or
7
+ * swipe sideways to move it and it eases to a stop on a card. While it
8
+ * moves the cards ripple and bow, the edges fade out, and a title sits under
9
+ * each card. Arrow keys step. Vertical wheel scrolling is left to the page.
9
10
  */
10
11
 
11
12
  const IK = "https://ik.imagekit.io/ny6yinyut/apexui/assets";
@@ -54,13 +55,13 @@ void main(){
54
55
  gl_FragColor = vec4(col, edge * f * uReady);
55
56
  }`;
56
57
 
57
- const ArcGallery = ({ items = DEFAULT_ITEMS, bend = 1, cardHeight = 0.62, aspectRatio = 0.78, gap = 0.22, cornerRadius = 0.05, wave = 1, pull = 1, fadeEdges = 0.14, ease = 0.08, snap = true, showTitles = true, height = "26rem", onChange, className = "" }) => {
58
+ const ArcGallery = ({ items = DEFAULT_ITEMS, bend = 1, cardHeight = 0.62, aspectRatio = 0.78, gap = 0.22, cornerRadius = 0.05, wave = 1, pull = 1, fadeEdges = 0.14, ease = 0.08, snap = true, autoScroll = 40, pauseOnHover = true, showTitles = true, height = "26rem", onChange, className = "" }) => {
58
59
  const wrap = useRef(null);
59
60
  const labels = useRef([]);
60
61
  const api = useRef(null);
61
62
  const [active, setActive] = useState(0);
62
63
  const live = useRef({});
63
- live.current = { bend, wave, pull, fadeEdges, ease, snap, onChange };
64
+ live.current = { bend, wave, pull, fadeEdges, ease, snap, autoScroll, pauseOnHover, onChange };
64
65
  const list = items.length ? items : DEFAULT_ITEMS;
65
66
  const key = list.map((i) => i.image).join("|");
66
67
 
@@ -84,7 +85,6 @@ const ArcGallery = ({ items = DEFAULT_ITEMS, bend = 1, cardHeight = 0.62, aspect
84
85
  const textures = list.map((item) => {
85
86
  const entry = { tex: null, aspect: 1, at: 0 };
86
87
  loader.load(item.image, (t) => {
87
- t.colorSpace = SRGBColorSpace;
88
88
  entry.tex = t;
89
89
  entry.aspect = t.image.width / t.image.height;
90
90
  entry.at = performance.now();
@@ -116,7 +116,8 @@ const ArcGallery = ({ items = DEFAULT_ITEMS, bend = 1, cardHeight = 0.62, aspect
116
116
  return { mesh, m };
117
117
  };
118
118
 
119
- const s = { scroll: 0, target: 0, vel: 0, pull: 0, drag: null, time: 0 };
119
+ const s = { scroll: 0, target: 0, vel: 0, pull: 0, drag: null, time: 0, hover: false, hold: 0 };
120
+ const reduced = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
120
121
  let cardW = 1;
121
122
  let cardH = 1;
122
123
  let spacing = 1;
@@ -172,6 +173,10 @@ const ArcGallery = ({ items = DEFAULT_ITEMS, bend = 1, cardHeight = 0.62, aspect
172
173
  const o = live.current;
173
174
  const dt = last ? Math.min(0.05, (now - last) / 1000) : 1 / 60;
174
175
  last = now;
176
+ // Auto scroll drifts the row along, pausing while hovered, dragged or
177
+ // just after someone moved it by hand.
178
+ const auto = o.autoScroll && !reduced && !s.drag && !(o.pauseOnHover && s.hover) && now > s.hold;
179
+ if (auto) s.target += o.autoScroll * dt;
175
180
  const before = s.scroll;
176
181
  const follow = 1 - Math.pow(1 - Math.min(1, Math.max(0.01, o.ease)), dt * 60);
177
182
  s.scroll += (s.target - s.scroll) * follow;
@@ -185,7 +190,7 @@ const ArcGallery = ({ items = DEFAULT_ITEMS, bend = 1, cardHeight = 0.62, aspect
185
190
  const pullGoal = Math.max(-1, Math.min(1, s.vel / (spacing * 7))) * 0.32 * o.pull;
186
191
  s.pull += (pullGoal - s.pull) * (1 - Math.exp(-dt / 0.12));
187
192
  if (Math.abs(s.pull) < 0.0005 && !pullGoal) s.pull = 0;
188
- let busy = s.pull !== 0 || s.scroll !== s.target || s.vel !== 0 || !!s.drag;
193
+ let busy = auto || s.pull !== 0 || s.scroll !== s.target || s.vel !== 0 || !!s.drag;
189
194
 
190
195
  const first = Math.floor((s.scroll - view.w / 2) / spacing) - 1;
191
196
  slots.forEach((slot, k) => {
@@ -251,6 +256,7 @@ const ArcGallery = ({ items = DEFAULT_ITEMS, bend = 1, cardHeight = 0.62, aspect
251
256
  const onDown = (e) => {
252
257
  if (e.button !== 0) return;
253
258
  s.drag = { x: e.clientX, start: s.target, moved: false, samples: [] };
259
+ wake();
254
260
  };
255
261
  const onMove = (e) => {
256
262
  const g = s.drag;
@@ -269,6 +275,7 @@ const ArcGallery = ({ items = DEFAULT_ITEMS, bend = 1, cardHeight = 0.62, aspect
269
275
  const onUp = (e) => {
270
276
  const g = s.drag;
271
277
  s.drag = null;
278
+ s.hold = performance.now() + 1800;
272
279
  if (!g) return;
273
280
  if (g.moved && g.samples.length > 1) {
274
281
  const a = g.samples[0];
@@ -286,10 +293,12 @@ const ArcGallery = ({ items = DEFAULT_ITEMS, bend = 1, cardHeight = 0.62, aspect
286
293
  settle();
287
294
  };
288
295
  const onWheel = (e) => {
289
- const d = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY;
290
- if (!d) return;
296
+ // Only sideways wheel / trackpad swipes move the row; vertical
297
+ // scrolling belongs to the page.
298
+ if (Math.abs(e.deltaX) <= Math.abs(e.deltaY)) return;
291
299
  e.preventDefault();
292
- s.target += d * 1.2;
300
+ s.target += e.deltaX * 1.2;
301
+ s.hold = performance.now() + 1800;
293
302
  clearTimeout(snapTimer);
294
303
  snapTimer = setTimeout(settle, 140);
295
304
  wake();
@@ -299,6 +308,13 @@ const ArcGallery = ({ items = DEFAULT_ITEMS, bend = 1, cardHeight = 0.62, aspect
299
308
  canvas.addEventListener("pointerup", onUp);
300
309
  canvas.addEventListener("pointercancel", onUp);
301
310
  canvas.addEventListener("wheel", onWheel, { passive: false });
311
+ const onEnter = () => (s.hover = true);
312
+ const onLeave = () => {
313
+ s.hover = false;
314
+ wake();
315
+ };
316
+ canvas.addEventListener("pointerenter", onEnter);
317
+ canvas.addEventListener("pointerleave", onLeave);
302
318
  const io = new IntersectionObserver(([entry]) => {
303
319
  visible = entry.isIntersecting;
304
320
  wake();
@@ -316,6 +332,8 @@ const ArcGallery = ({ items = DEFAULT_ITEMS, bend = 1, cardHeight = 0.62, aspect
316
332
  canvas.removeEventListener("pointerup", onUp);
317
333
  canvas.removeEventListener("pointercancel", onUp);
318
334
  canvas.removeEventListener("wheel", onWheel);
335
+ canvas.removeEventListener("pointerenter", onEnter);
336
+ canvas.removeEventListener("pointerleave", onLeave);
319
337
  textures.forEach((t) => t.tex?.dispose());
320
338
  slots.forEach((slot) => slot.m.dispose());
321
339
  geometry.dispose();
@@ -124,7 +124,6 @@ const CircularCarousel = ({
124
124
  },
125
125
  });
126
126
  loader.load(item.src, (t) => {
127
- t.colorSpace = SRGBColorSpace;
128
127
  m.uniforms.uTex.value = t;
129
128
  const ia = t.image.width / t.image.height;
130
129
  // Cover crop: show the middle of the image at the card's shape.
@@ -0,0 +1,250 @@
1
+ import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
2
+ import { CornerDownLeft, FileText, Github, LayoutGrid, LogOut, Moon, Plus, Search, Settings, Sparkles, User } from "lucide-react";
3
+
4
+ /*
5
+ * Command Palette: a ⌘K / Ctrl+K search box for jumping anywhere. Typing
6
+ * filters with a forgiving fuzzy match (letters in order, gaps allowed) and
7
+ * highlights the matched letters; arrow keys move a highlight that slides
8
+ * between rows, Enter runs the command, Escape closes. The panel eases in and
9
+ * out with CSS transitions only, so it never blinks.
10
+ */
11
+
12
+ const DEFAULT_GROUPS = [
13
+ {
14
+ heading: "Suggestions",
15
+ items: [
16
+ { id: "new", label: "Create new project", icon: Plus, shortcut: "N" },
17
+ { id: "components", label: "Browse components", icon: LayoutGrid, hint: "107 ready to use" },
18
+ { id: "ai", label: "Ask AI to build a section", icon: Sparkles, keywords: "assistant generate" },
19
+ ],
20
+ },
21
+ {
22
+ heading: "Pages",
23
+ items: [
24
+ { id: "docs", label: "Documentation", icon: FileText },
25
+ { id: "github", label: "Open GitHub", icon: Github, keywords: "repo source" },
26
+ ],
27
+ },
28
+ {
29
+ heading: "Settings",
30
+ items: [
31
+ { id: "profile", label: "Profile", icon: User, shortcut: "P" },
32
+ { id: "theme", label: "Toggle dark mode", icon: Moon, keywords: "theme light" },
33
+ { id: "settings", label: "Preferences", icon: Settings, shortcut: "," },
34
+ { id: "logout", label: "Log out", icon: LogOut },
35
+ ],
36
+ },
37
+ ];
38
+
39
+ // Letters in order, gaps allowed. Lower score is better; returns matched indexes.
40
+ function fuzzy(text, query) {
41
+ if (!query) return { score: 0, hits: [] };
42
+ const t = text.toLowerCase();
43
+ const q = query.toLowerCase();
44
+ let ti = 0;
45
+ let score = 0;
46
+ let prev = -1;
47
+ const hits = [];
48
+ for (const ch of q) {
49
+ if (ch === " ") continue;
50
+ const at = t.indexOf(ch, ti);
51
+ if (at < 0) return null;
52
+ score += at - prev - 1 + (at === 0 || t[at - 1] === " " ? 0 : 0.5);
53
+ hits.push(at);
54
+ prev = at;
55
+ ti = at + 1;
56
+ }
57
+ return { score, hits };
58
+ }
59
+
60
+ const Highlight = ({ text, hits }) => {
61
+ if (!hits.length) return text;
62
+ const set = new Set(hits);
63
+ return [...text].map((ch, i) => (set.has(i) ? <mark key={i} className="bg-transparent font-semibold text-[var(--cp-accent)]">{ch}</mark> : ch));
64
+ };
65
+
66
+ const CommandPalette = ({ groups = DEFAULT_GROUPS, placeholder = "Type a command or search…", hotkey = "k", accentColor = "#a3e635", theme = "dark", contained = false, triggerLabel = "Search", defaultOpen = false, onSelect, className = "" }) => {
67
+ const [open, setOpen] = useState(defaultOpen);
68
+ const [mounted, setMounted] = useState(defaultOpen);
69
+ const [shown, setShown] = useState(defaultOpen);
70
+ const [query, setQuery] = useState("");
71
+ const [active, setActive] = useState(0);
72
+ const [done, setDone] = useState(null);
73
+ const input = useRef(null);
74
+ const listRef = useRef(null);
75
+ const bar = useRef(null);
76
+ const rows = useRef([]);
77
+ const dark = theme !== "light";
78
+
79
+ const results = useMemo(() => {
80
+ const out = [];
81
+ groups.forEach((g) => {
82
+ const items = g.items
83
+ .map((it) => {
84
+ const m = fuzzy(it.label, query) ?? (it.keywords && fuzzy(it.keywords, query) ? { score: 50, hits: [] } : null);
85
+ return m && { ...it, ...m };
86
+ })
87
+ .filter(Boolean)
88
+ .sort((a, b) => a.score - b.score);
89
+ if (items.length) out.push({ heading: g.heading, items });
90
+ });
91
+ return out;
92
+ }, [groups, query]);
93
+ const flat = useMemo(() => results.flatMap((g) => g.items), [results]);
94
+
95
+ // Mount, then show on the next frame so the enter transition runs; hide, then unmount.
96
+ useEffect(() => {
97
+ if (open) {
98
+ setMounted(true);
99
+ const id = requestAnimationFrame(() => requestAnimationFrame(() => setShown(true)));
100
+ return () => cancelAnimationFrame(id);
101
+ }
102
+ setShown(false);
103
+ const t = setTimeout(() => setMounted(false), 220);
104
+ return () => clearTimeout(t);
105
+ }, [open]);
106
+
107
+ useEffect(() => {
108
+ if (shown) input.current?.focus({ preventScroll: true });
109
+ }, [shown]);
110
+
111
+ useEffect(() => {
112
+ const onKey = (e) => {
113
+ if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === hotkey) {
114
+ e.preventDefault();
115
+ setOpen((o) => !o);
116
+ }
117
+ };
118
+ window.addEventListener("keydown", onKey);
119
+ return () => window.removeEventListener("keydown", onKey);
120
+ }, [hotkey]);
121
+
122
+ useEffect(() => setActive(0), [query]);
123
+
124
+ // Slide the highlight to the active row and keep it in view.
125
+ useLayoutEffect(() => {
126
+ const row = rows.current[active];
127
+ const b = bar.current;
128
+ if (!row || !b) {
129
+ if (b) b.style.opacity = "0";
130
+ return;
131
+ }
132
+ b.style.opacity = "1";
133
+ b.style.transform = `translateY(${row.offsetTop}px)`;
134
+ b.style.height = `${row.offsetHeight}px`;
135
+ row.scrollIntoView({ block: "nearest" });
136
+ }, [active, flat.length, mounted]);
137
+
138
+ const close = useCallback(() => {
139
+ setOpen(false);
140
+ setTimeout(() => setQuery(""), 220);
141
+ }, []);
142
+ const run = (item) => {
143
+ if (!item) return;
144
+ onSelect?.(item);
145
+ setDone(item.label);
146
+ setTimeout(() => setDone(null), 1600);
147
+ close();
148
+ };
149
+
150
+ const onKeyDown = (e) => {
151
+ if (e.key === "ArrowDown") setActive((a) => (flat.length ? (a + 1) % flat.length : 0));
152
+ else if (e.key === "ArrowUp") setActive((a) => (flat.length ? (a - 1 + flat.length) % flat.length : 0));
153
+ else if (e.key === "Home") setActive(0);
154
+ else if (e.key === "End") setActive(Math.max(0, flat.length - 1));
155
+ else if (e.key === "Enter") run(flat[active]);
156
+ else if (e.key === "Escape") close();
157
+ else return;
158
+ e.preventDefault();
159
+ };
160
+
161
+ const t = dark
162
+ ? { panel: "border-white/10 bg-neutral-950/90 text-white", muted: "text-white/45", line: "border-white/10", row: "text-white/85", hl: "bg-white/[0.07]", kbd: "border-white/15 bg-white/5 text-white/60", trigger: "border-white/10 bg-neutral-900 text-white/70 hover:text-white" }
163
+ : { panel: "border-black/10 bg-white/95 text-neutral-900", muted: "text-neutral-400", line: "border-black/10", row: "text-neutral-700", hl: "bg-black/[0.05]", kbd: "border-black/10 bg-black/[0.03] text-neutral-500", trigger: "border-black/10 bg-white text-neutral-500 hover:text-neutral-900" };
164
+ let n = -1;
165
+ const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
166
+
167
+ return (
168
+ <div className={className} style={{ "--cp-accent": accentColor }}>
169
+ <button type="button" onClick={() => setOpen(true)} className={`inline-flex w-full max-w-xs items-center gap-2.5 rounded-xl border px-3.5 py-2.5 text-sm transition-colors ${t.trigger}`}>
170
+ <Search className="h-4 w-4" />
171
+ <span className="flex-1 text-left">{triggerLabel}</span>
172
+ <kbd className={`rounded-md border px-1.5 py-0.5 font-mono text-[0.68rem] ${t.kbd}`}>{isMac ? "⌘" : "Ctrl"} {hotkey.toUpperCase()}</kbd>
173
+ </button>
174
+ {done && <p className={`mt-3 text-center text-xs ${t.muted}`}>Ran “{done}”</p>}
175
+
176
+ {mounted && (
177
+ <div className={`${contained ? "absolute" : "fixed"} inset-0 z-[300] flex items-start justify-center px-3 pt-[12vh]`} onPointerDown={(e) => e.target === e.currentTarget && close()}>
178
+ <div aria-hidden="true" className="absolute inset-0 bg-black/50 backdrop-blur-[2px] transition-opacity duration-200 ease-out" style={{ opacity: shown ? 1 : 0 }} onPointerDown={close} />
179
+ <div
180
+ role="dialog"
181
+ aria-modal="true"
182
+ aria-label="Command palette"
183
+ className={`relative w-full max-w-lg overflow-hidden rounded-2xl border shadow-[0_30px_80px_-20px_rgba(0,0,0,0.6)] backdrop-blur-xl ${t.panel}`}
184
+ style={{ opacity: shown ? 1 : 0, transform: shown ? "none" : "translateY(-10px) scale(0.97)", transition: "opacity 180ms ease-out, transform 260ms cubic-bezier(0.22, 1, 0.36, 1)" }}
185
+ >
186
+ <div className={`flex items-center gap-3 border-b px-4 ${t.line}`}>
187
+ <Search className={`h-4 w-4 shrink-0 ${t.muted}`} />
188
+ <input
189
+ ref={input}
190
+ value={query}
191
+ onChange={(e) => setQuery(e.target.value)}
192
+ onKeyDown={onKeyDown}
193
+ placeholder={placeholder}
194
+ role="combobox"
195
+ aria-expanded="true"
196
+ aria-controls="cp-list"
197
+ aria-activedescendant={flat[active] ? `cp-${flat[active].id}` : undefined}
198
+ className="h-12 w-full bg-transparent text-[0.95rem] outline-none placeholder:opacity-50"
199
+ />
200
+ <kbd className={`hidden rounded-md border px-1.5 py-0.5 font-mono text-[0.65rem] sm:block ${t.kbd}`}>ESC</kbd>
201
+ </div>
202
+ <div ref={listRef} id="cp-list" role="listbox" className="relative max-h-[min(22rem,55vh)] overflow-y-auto overscroll-contain p-2 [scrollbar-width:thin]">
203
+ <div ref={bar} aria-hidden="true" className={`pointer-events-none absolute inset-x-2 top-0 rounded-xl ${t.hl}`} style={{ transition: "transform 180ms cubic-bezier(0.22, 1, 0.36, 1), height 180ms ease, opacity 120ms" }}>
204
+ <span className="absolute left-0 top-1/2 h-4 w-[3px] -translate-y-1/2 rounded-full bg-[var(--cp-accent)]" />
205
+ </div>
206
+ {results.length === 0 && <p className={`px-3 py-10 text-center text-sm ${t.muted}`}>No results for “{query}”</p>}
207
+ {results.map((g) => (
208
+ <div key={g.heading} role="group" aria-label={g.heading}>
209
+ <p className={`px-3 pb-1 pt-2.5 text-[0.68rem] font-semibold uppercase tracking-[0.14em] ${t.muted}`}>{g.heading}</p>
210
+ {g.items.map((it) => {
211
+ n += 1;
212
+ const i = n;
213
+ const Icon = it.icon;
214
+ return (
215
+ <div
216
+ key={it.id}
217
+ id={`cp-${it.id}`}
218
+ ref={(e) => (rows.current[i] = e)}
219
+ role="option"
220
+ aria-selected={i === active}
221
+ onPointerMove={() => i !== active && setActive(i)}
222
+ onClick={() => run(it)}
223
+ className={`relative flex cursor-pointer items-center gap-3 rounded-xl px-3 py-2.5 text-sm ${t.row}`}
224
+ >
225
+ {Icon && <Icon className={`h-4 w-4 shrink-0 transition-colors ${i === active ? "text-[var(--cp-accent)]" : t.muted}`} />}
226
+ <span className="flex-1 truncate">
227
+ <Highlight text={it.label} hits={it.hits} />
228
+ {it.hint && <span className={`ml-2 text-xs ${t.muted}`}>{it.hint}</span>}
229
+ </span>
230
+ {it.shortcut && <kbd className={`rounded-md border px-1.5 py-0.5 font-mono text-[0.65rem] ${t.kbd}`}>{isMac ? "⌘" : "Ctrl"} {it.shortcut}</kbd>}
231
+ {i === active && <CornerDownLeft className={`h-3.5 w-3.5 ${t.muted}`} />}
232
+ </div>
233
+ );
234
+ })}
235
+ </div>
236
+ ))}
237
+ </div>
238
+ <div className={`flex items-center gap-4 border-t px-4 py-2.5 text-[0.7rem] ${t.line} ${t.muted}`}>
239
+ <span>↑↓ to move</span>
240
+ <span>↵ to run</span>
241
+ <span className="ml-auto">esc to close</span>
242
+ </div>
243
+ </div>
244
+ </div>
245
+ )}
246
+ </div>
247
+ );
248
+ };
249
+
250
+ export default CommandPalette;