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.
- package/ReadMe.md +19 -9
- package/dist/ApexUI-Kit/templates/AnimatedTabs/AnimatedTabs.jsx +1 -1
- package/dist/ApexUI-Kit/templates/CommandPalette/CommandPalette.jsx +250 -0
- package/dist/ApexUI-Kit/templates/CrossedMarquee/CrossedMarquee.jsx +158 -0
- package/dist/ApexUI-Kit/templates/DitherWaves/DitherWaves.jsx +207 -0
- package/dist/ApexUI-Kit/templates/GlassLens/GlassLens.jsx +274 -0
- package/dist/ApexUI-Kit/templates/GooeyNav/GooeyNav.jsx +1 -1
- package/dist/ApexUI-Kit/templates/ImageAccordion/ImageAccordion.jsx +116 -0
- package/dist/ApexUI-Kit/templates/InfiniteCanvas/InfiniteCanvas.jsx +230 -0
- package/dist/ApexUI-Kit/templates/OrbitIcons/OrbitIcons.jsx +169 -0
- package/dist/ApexUI-Kit/templates/RadialMenu/RadialMenu.jsx +20 -4
- package/dist/ApexUI-Kit/templates/TextRing3D/TextRing3D.jsx +18 -3
- package/dist/ApexUI-Kit/templates/ThemeSwitcher/ThemeSwitcher.jsx +215 -0
- package/dist/ApexUI-Kit/templates/ThemeSwitcher/themes.js +38 -0
- package/dist/ApexUI-Kit/templates/ToastStack/ToastStack.jsx +21 -13
- package/dist/cli/index.js +9 -0
- package/package.json +2 -2
package/ReadMe.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
[](https://www.npmjs.com/package/apex-ui-kit)
|
|
5
5
|
[](./Licence.md)
|
|
6
6
|
|
|
7
|
-
**
|
|
7
|
+
**115 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
|
-
- **
|
|
18
|
+
- **115 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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
199
|
+
### Interactive (30)
|
|
195
200
|
|
|
196
201
|
| Component | CLI name |
|
|
197
202
|
| --- | --- |
|
|
@@ -210,11 +215,14 @@ 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` |
|
|
215
222
|
| [Magnet Lines](https://apex-ui.in/components/magnet-lines) | `magnet-lines` |
|
|
216
223
|
| [Magnetic Cursor](https://apex-ui.in/components/magnetic-cursor) | `magnetic-cursor` |
|
|
217
224
|
| [Marquee 3D](https://apex-ui.in/components/marquee-3d) | `marquee-3d` |
|
|
225
|
+
| [Orbit Icons](https://apex-ui.in/components/orbit-icons) | `orbit-icons` |
|
|
218
226
|
| [Particle Morph](https://apex-ui.in/components/particle-morph) | `particle-morph` |
|
|
219
227
|
| [Pointer Follower](https://apex-ui.in/components/pointer-follower) | `pointer-follower` |
|
|
220
228
|
| [Receipt Printer](https://apex-ui.in/components/receipt-printer) | `receipt-printer` |
|
|
@@ -251,9 +259,11 @@ Every component's props, variants and copy-ready code are on its docs page.
|
|
|
251
259
|
| [Dot Globe](https://apex-ui.in/components/dot-globe) | `dot-globe` |
|
|
252
260
|
| [World Map](https://apex-ui.in/components/world-map) | `world-map` |
|
|
253
261
|
|
|
254
|
-
## What's new in 2.
|
|
262
|
+
## What's new in 2.8
|
|
255
263
|
|
|
256
|
-
-
|
|
264
|
+
- 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).
|
|
265
|
+
- 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.
|
|
266
|
+
- 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
267
|
- 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
268
|
- AI Model Prompt now exports `DEFAULT_LLM_MODELS` and an index file, so you can import both from the folder.
|
|
259
269
|
- 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
|
|
@@ -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;
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import React, { useEffect, useRef } from "react";
|
|
2
|
+
import { siExpress, siJavascript, siMongodb, siNextdotjs, siNodedotjs, siReact, siRedux, siTailwindcss, siTypescript, siVite } from "simple-icons";
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
* Crossed Marquee: two tilted bands crossing like an "X" and scrolling in
|
|
6
|
+
* opposite directions. The front band shows brand tiles and big labels, the
|
|
7
|
+
* back band a quieter label row. Scrolling the page bursts the bands forward
|
|
8
|
+
* (or reverses them when you scroll up), then they settle back to a cruise.
|
|
9
|
+
*
|
|
10
|
+
* Props: items (strings or { label, icon }), duration = 45 (s per loop),
|
|
11
|
+
* tilt = 3 (deg), accentColor, bandInk, backColor, tileColor, showBackBand,
|
|
12
|
+
* reactToScroll, pauseOnHover, className. Colours can be CSS variables.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const ICONS = { mongodb: siMongodb, expressjs: siExpress, express: siExpress, react: siReact, nodejs: siNodedotjs, javascript: siJavascript, redux: siRedux, tailwindcss: siTailwindcss, tailwind: siTailwindcss, vite: siVite, nextjs: siNextdotjs, typescript: siTypescript };
|
|
16
|
+
const DEFAULT_ITEMS = ["MongoDB", "Express.js", "React", "Node.js", "JavaScript", "Redux", "Tailwind CSS", "Vite"];
|
|
17
|
+
|
|
18
|
+
const keyOf = (s) => String(s).toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
19
|
+
const wrap = (v) => ((((v + 50) % 50) + 50) % 50) - 50;
|
|
20
|
+
const initials = (s) =>
|
|
21
|
+
String(s)
|
|
22
|
+
.split(/[\s.]+/)
|
|
23
|
+
.filter(Boolean)
|
|
24
|
+
.slice(0, 2)
|
|
25
|
+
.map((w) => w[0].toUpperCase())
|
|
26
|
+
.join("");
|
|
27
|
+
|
|
28
|
+
const normalise = (it) => (typeof it === "string" ? { label: it, icon: ICONS[keyOf(it)] } : { ...it, icon: it.icon ?? ICONS[keyOf(it.label)] });
|
|
29
|
+
|
|
30
|
+
const BrandIcon = ({ icon, label }) => {
|
|
31
|
+
if (icon && typeof icon === "object" && "path" in icon) {
|
|
32
|
+
return (
|
|
33
|
+
<svg viewBox="0 0 24 24" className="h-6 w-6 md:h-7 md:w-7" aria-hidden="true">
|
|
34
|
+
<path d={icon.path} fill={`#${icon.hex}`} />
|
|
35
|
+
</svg>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
if (icon) return icon;
|
|
39
|
+
return <span className="text-sm font-black md:text-base">{initials(label)}</span>;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const CrossedMarquee = ({ items = DEFAULT_ITEMS, duration = 45, tilt = 3, accentColor = "#ff5b1f", bandInk = "#14110d", backColor = "#121110", tileColor = "#fffaf2", showBackBand = true, reactToScroll = true, pauseOnHover = false, className = "" }) => {
|
|
43
|
+
const root = useRef(null);
|
|
44
|
+
const front = useRef(null);
|
|
45
|
+
const back = useRef(null);
|
|
46
|
+
const live = useRef({});
|
|
47
|
+
live.current = { duration, reactToScroll, pauseOnHover };
|
|
48
|
+
const list = (items.length ? items : DEFAULT_ITEMS).map(normalise);
|
|
49
|
+
// Repeat the list so one copy is always wider than the screen.
|
|
50
|
+
const row = Array.from({ length: Math.max(2, Math.ceil(12 / list.length)) }, () => list).flat();
|
|
51
|
+
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
const el = root.current;
|
|
54
|
+
const reduced = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
|
|
55
|
+
if (reduced) return undefined;
|
|
56
|
+
const s = { a: 0, b: -25, dir: 1, speed: 1, lastY: window.scrollY, lastT: performance.now(), hover: false };
|
|
57
|
+
let raf = 0;
|
|
58
|
+
let last = 0;
|
|
59
|
+
let visible = true;
|
|
60
|
+
|
|
61
|
+
const frame = (now) => {
|
|
62
|
+
raf = 0;
|
|
63
|
+
const o = live.current;
|
|
64
|
+
const dt = last ? Math.min(0.05, (now - last) / 1000) : 1 / 60;
|
|
65
|
+
last = now;
|
|
66
|
+
s.speed += (s.dir - s.speed) * 0.05;
|
|
67
|
+
const k = o.pauseOnHover && s.hover ? 0 : 1;
|
|
68
|
+
const d = Math.max(1, o.duration);
|
|
69
|
+
s.a = wrap(s.a - (50 / d) * s.speed * dt * k);
|
|
70
|
+
s.b = wrap(s.b + (50 / (d * 1.22)) * s.speed * dt * k);
|
|
71
|
+
if (front.current) front.current.style.transform = `translate3d(${s.a}%, 0, 0)`;
|
|
72
|
+
if (back.current) back.current.style.transform = `translate3d(${s.b}%, 0, 0)`;
|
|
73
|
+
if (visible) raf = requestAnimationFrame(frame);
|
|
74
|
+
else last = 0;
|
|
75
|
+
};
|
|
76
|
+
const start = () => {
|
|
77
|
+
if (!raf && visible) raf = requestAnimationFrame(frame);
|
|
78
|
+
};
|
|
79
|
+
const onScroll = () => {
|
|
80
|
+
if (!live.current.reactToScroll) return;
|
|
81
|
+
const now = performance.now();
|
|
82
|
+
const dy = window.scrollY - s.lastY;
|
|
83
|
+
const dtS = Math.max(0.016, (now - s.lastT) / 1000);
|
|
84
|
+
s.lastY = window.scrollY;
|
|
85
|
+
s.lastT = now;
|
|
86
|
+
if (!dy) return;
|
|
87
|
+
s.dir = Math.sign(dy);
|
|
88
|
+
s.speed = s.dir * (1 + Math.min(Math.abs(dy) / dtS / 350, 5));
|
|
89
|
+
};
|
|
90
|
+
const onEnter = () => (s.hover = true);
|
|
91
|
+
const onLeave = () => (s.hover = false);
|
|
92
|
+
window.addEventListener("scroll", onScroll, { passive: true });
|
|
93
|
+
el.addEventListener("pointerenter", onEnter);
|
|
94
|
+
el.addEventListener("pointerleave", onLeave);
|
|
95
|
+
const io = new IntersectionObserver(([entry]) => {
|
|
96
|
+
visible = entry.isIntersecting;
|
|
97
|
+
if (visible) start();
|
|
98
|
+
});
|
|
99
|
+
io.observe(el);
|
|
100
|
+
start();
|
|
101
|
+
return () => {
|
|
102
|
+
cancelAnimationFrame(raf);
|
|
103
|
+
io.disconnect();
|
|
104
|
+
window.removeEventListener("scroll", onScroll);
|
|
105
|
+
el.removeEventListener("pointerenter", onEnter);
|
|
106
|
+
el.removeEventListener("pointerleave", onLeave);
|
|
107
|
+
};
|
|
108
|
+
}, []);
|
|
109
|
+
|
|
110
|
+
const band = "absolute left-[-5%] right-[-5%] overflow-hidden";
|
|
111
|
+
return (
|
|
112
|
+
<div ref={root} aria-label={`Tech stack: ${list.map((i) => i.label).join(", ")}`} className={`relative w-full overflow-hidden pb-24 pt-14 md:pb-32 md:pt-20 ${className}`}>
|
|
113
|
+
{/* Spacer so the wrapper has the height of the front band. */}
|
|
114
|
+
<div aria-hidden="true" className="invisible flex items-center py-4 md:py-5">
|
|
115
|
+
<span className="block h-11 md:h-14" />
|
|
116
|
+
</div>
|
|
117
|
+
{showBackBand && (
|
|
118
|
+
<div aria-hidden="true" className={band} style={{ top: "60%", transform: `translateY(-50%) rotate(${tilt}deg)`, background: backColor, borderBlock: `2px solid ${bandInk}` }}>
|
|
119
|
+
<div ref={back} className="flex w-max will-change-transform" style={{ transform: "translate3d(-25%, 0, 0)" }}>
|
|
120
|
+
{[0, 1].map((copy) => (
|
|
121
|
+
<div key={copy} className="flex shrink-0 items-center py-3 md:py-4">
|
|
122
|
+
{row.map((it, i) => (
|
|
123
|
+
<span key={i} className="flex items-center">
|
|
124
|
+
<span className="whitespace-nowrap text-lg font-bold uppercase tracking-tight md:text-2xl" style={{ color: "rgba(255,255,255,.8)" }}>
|
|
125
|
+
{it.label}
|
|
126
|
+
</span>
|
|
127
|
+
<span className="mx-5 block h-2.5 w-2.5 rotate-45 md:mx-8 md:h-3 md:w-3" style={{ background: accentColor }} />
|
|
128
|
+
</span>
|
|
129
|
+
))}
|
|
130
|
+
</div>
|
|
131
|
+
))}
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
)}
|
|
135
|
+
<div aria-hidden="true" className={band} style={{ top: "50%", transform: `translateY(-50%) rotate(${-tilt}deg)`, background: accentColor, borderBlock: `2px solid ${bandInk}`, boxShadow: "0 10px 0 rgb(0 0 0 / .08)" }}>
|
|
136
|
+
<div ref={front} className="flex w-max will-change-transform">
|
|
137
|
+
{[0, 1].map((copy) => (
|
|
138
|
+
<div key={copy} className="flex shrink-0 items-center py-4 md:py-5">
|
|
139
|
+
{row.map((it, i) => (
|
|
140
|
+
<span key={i} className="flex items-center">
|
|
141
|
+
<span className="mr-3 grid h-11 w-11 shrink-0 place-items-center rounded-xl md:mr-4 md:h-14 md:w-14" style={{ background: tileColor, border: `2px solid ${bandInk}`, boxShadow: `4px 4px 0 ${bandInk}`, color: bandInk }}>
|
|
142
|
+
<BrandIcon icon={it.icon} label={it.label} />
|
|
143
|
+
</span>
|
|
144
|
+
<span className="whitespace-nowrap font-extrabold uppercase leading-none tracking-tight" style={{ fontSize: "clamp(1.5rem, 3.4vw, 3rem)", color: bandInk }}>
|
|
145
|
+
{it.label}
|
|
146
|
+
</span>
|
|
147
|
+
<span className="mx-5 block h-2.5 w-2.5 rotate-45 md:mx-8 md:h-3 md:w-3" style={{ background: bandInk }} />
|
|
148
|
+
</span>
|
|
149
|
+
))}
|
|
150
|
+
</div>
|
|
151
|
+
))}
|
|
152
|
+
</div>
|
|
153
|
+
</div>
|
|
154
|
+
</div>
|
|
155
|
+
);
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
export default CrossedMarquee;
|