apex-ui-kit 2.5.0 → 2.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ReadMe.md +23 -11
- package/dist/ApexUI-Kit/templates/AccordionMarquee/AccordionMarquee.jsx +340 -161
- package/dist/ApexUI-Kit/templates/AnimatedTabs/AnimatedTabs.jsx +1 -1
- package/dist/ApexUI-Kit/templates/ArcGallery/ArcGallery.jsx +356 -0
- package/dist/ApexUI-Kit/templates/BounceCards/BounceCards.jsx +22 -2
- package/dist/ApexUI-Kit/templates/CircularCarousel/CircularCarousel.jsx +381 -0
- package/dist/ApexUI-Kit/templates/EdgeBlur/EdgeBlur.jsx +92 -0
- package/dist/ApexUI-Kit/templates/FlipClock/FlipClock.jsx +5 -1
- package/dist/ApexUI-Kit/templates/GooeyNav/GooeyNav.jsx +2 -2
- package/dist/ApexUI-Kit/templates/HorizonGlow/HorizonGlow.jsx +259 -0
- package/dist/ApexUI-Kit/templates/LoadingScreen/LoadingScreen.jsx +40 -48
- package/dist/ApexUI-Kit/templates/LoadingScreen/usePageProgress.js +135 -0
- package/dist/ApexUI-Kit/templates/MagneticButton/MagneticButton.jsx +69 -0
- package/dist/ApexUI-Kit/templates/Marquee3D/Marquee3D.jsx +68 -0
- package/dist/ApexUI-Kit/templates/NavDropDown/NavDropDown.jsx +123 -1
- package/dist/ApexUI-Kit/templates/ParticleText/ParticleText.jsx +328 -0
- package/dist/ApexUI-Kit/templates/RadialMenu/RadialMenu.jsx +103 -0
- package/dist/ApexUI-Kit/templates/ScrollStack/ScrollStack.jsx +88 -0
- package/dist/ApexUI-Kit/templates/SidebarNav/SidebarNav.jsx +22 -1
- package/dist/ApexUI-Kit/templates/SunriseBackground/SunriseBackground.jsx +290 -0
- package/dist/ApexUI-Kit/templates/WireframeKnot3D/WireframeKnot3D.jsx +112 -0
- package/dist/cli/index.js +11 -0
- package/package.json +2 -2
- package/dist/ApexUI-Kit/templates/AccordionMarquee/AccordionMarquee.css +0 -1
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { motion, useAnimationFrame, useMotionValue, useReducedMotion, useTransform } from "framer-motion";
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
* Marquee 3D: a wall of cards tilted back in 3D, with columns that scroll in
|
|
6
|
+
* opposite directions forever. Great for showing off screenshots, logos or
|
|
7
|
+
* testimonials. Each column repeats its items twice and moves exactly one
|
|
8
|
+
* copy, so the loop has no seam. Hover pauses; edges fade out.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const IK = "https://ik.imagekit.io/ny6yinyut/apexui/assets/componentsimage";
|
|
12
|
+
const shot = (f) => `${IK}/${f}?tr=f-auto,q-75,w-420`;
|
|
13
|
+
const DEFAULT_IMAGES = [
|
|
14
|
+
"gooey-nav-v3.png", "liquid-blob-3d.png", "galaxy-3d.png", "warp-tunnel.png", "retro-grid-3d.png", "particle-morph.png",
|
|
15
|
+
"distortion-image.png", "cube-gallery-v2.png", "coverflow-3d.png", "ai-voice-orb.png", "flip-book-v2.png", "holo-card.png",
|
|
16
|
+
"dot-globe.png", "3d-carousel.png", "pricing-cards.png", "stepper.png",
|
|
17
|
+
].map(shot);
|
|
18
|
+
|
|
19
|
+
function Column({ images, duration, reverse, paused }) {
|
|
20
|
+
const reduced = useReducedMotion();
|
|
21
|
+
const list = [...images, ...images];
|
|
22
|
+
// Progress through one copy (0 to 50%), advanced every frame. Pausing just
|
|
23
|
+
// stops advancing, so hover-out continues from the same spot (no jump).
|
|
24
|
+
const offset = useMotionValue(0);
|
|
25
|
+
useAnimationFrame((_, delta) => {
|
|
26
|
+
if (paused || reduced) return;
|
|
27
|
+
offset.set((offset.get() + (Math.min(delta, 50) / 1000 / duration) * 50) % 50);
|
|
28
|
+
});
|
|
29
|
+
const y = useTransform(offset, (v) => `${reverse ? v - 50 : -v}%`);
|
|
30
|
+
return (
|
|
31
|
+
<div className="relative h-full overflow-hidden">
|
|
32
|
+
<motion.div className="flex flex-col gap-4" style={{ y }}>
|
|
33
|
+
{list.map((src, i) => (
|
|
34
|
+
<img key={i} src={src} alt="" draggable="false" loading="lazy" className="aspect-[4/3] w-full rounded-xl bg-neutral-900 object-cover shadow-[0_10px_30px_-12px_rgba(0,0,0,0.6)] ring-1 ring-white/10" />
|
|
35
|
+
))}
|
|
36
|
+
</motion.div>
|
|
37
|
+
</div>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const Marquee3D = ({ images = DEFAULT_IMAGES, columns = 4, speed = 1, tilt = 1, pauseOnHover = true, height = "100%", className = "" }) => {
|
|
42
|
+
const [hover, setHover] = React.useState(false);
|
|
43
|
+
const cols = Array.from({ length: columns }, (_, c) => images.filter((_, i) => i % columns === c));
|
|
44
|
+
return (
|
|
45
|
+
<div
|
|
46
|
+
className={`relative w-full overflow-hidden [perspective:1000px] ${className}`}
|
|
47
|
+
style={{ height, maskImage: "linear-gradient(to bottom, transparent, black 18%, black 82%, transparent)", WebkitMaskImage: "linear-gradient(to bottom, transparent, black 18%, black 82%, transparent)" }}
|
|
48
|
+
onPointerEnter={() => pauseOnHover && setHover(true)}
|
|
49
|
+
onPointerLeave={() => setHover(false)}
|
|
50
|
+
aria-hidden="true"
|
|
51
|
+
>
|
|
52
|
+
<div
|
|
53
|
+
className="absolute left-1/2 top-1/2 grid h-[170%] w-[115%] gap-4"
|
|
54
|
+
style={{
|
|
55
|
+
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
|
|
56
|
+
transform: `translate(-50%, -50%) rotateX(${32 * tilt}deg) rotateZ(${-22 * tilt}deg) rotateY(${8 * tilt}deg)`,
|
|
57
|
+
transformStyle: "preserve-3d",
|
|
58
|
+
}}
|
|
59
|
+
>
|
|
60
|
+
{cols.map((imgs, c) => (
|
|
61
|
+
<Column key={c} images={imgs.length ? imgs : images} duration={(28 + c * 6) / speed} reverse={c % 2 === 1} paused={hover} />
|
|
62
|
+
))}
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export default Marquee3D;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React, { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from 'react';
|
|
2
2
|
import { AnimatePresence, LayoutGroup, MotionConfig, motion } from 'framer-motion';
|
|
3
|
-
import { ArrowRight, ArrowUpRight, ChevronDown } from 'lucide-react';
|
|
3
|
+
import { ArrowRight, ArrowUpRight, ChevronDown, Menu, X } from 'lucide-react';
|
|
4
4
|
|
|
5
5
|
const ease = [0.22, 1, 0.36, 1];
|
|
6
6
|
const CLOSE_DELAY = 140;
|
|
@@ -70,7 +70,129 @@ const PanelContent = ({ item }) => {
|
|
|
70
70
|
return <Services items={item.submenu} />;
|
|
71
71
|
};
|
|
72
72
|
|
|
73
|
+
/*
|
|
74
|
+
* Phones: a Menu button opens one full-width sheet; each section expands in
|
|
75
|
+
* place (accordion) instead of a hover panel that would not fit.
|
|
76
|
+
*/
|
|
77
|
+
const MobileNav = ({ navData }) => {
|
|
78
|
+
const [open, setOpen] = useState(false);
|
|
79
|
+
const [section, setSection] = useState(null);
|
|
80
|
+
const root = useRef(null);
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
if (!open) return undefined;
|
|
83
|
+
const down = (e) => !root.current?.contains(e.target) && setOpen(false);
|
|
84
|
+
const key = (e) => e.key === 'Escape' && setOpen(false);
|
|
85
|
+
window.addEventListener('pointerdown', down);
|
|
86
|
+
window.addEventListener('keydown', key);
|
|
87
|
+
return () => {
|
|
88
|
+
window.removeEventListener('pointerdown', down);
|
|
89
|
+
window.removeEventListener('keydown', key);
|
|
90
|
+
};
|
|
91
|
+
}, [open]);
|
|
92
|
+
return (
|
|
93
|
+
<div ref={root} className="relative w-full max-w-sm">
|
|
94
|
+
<button
|
|
95
|
+
type="button"
|
|
96
|
+
aria-expanded={open}
|
|
97
|
+
onClick={() => setOpen((o) => !o)}
|
|
98
|
+
className="flex w-full items-center justify-between rounded-full border border-white/10 bg-zinc-950/85 px-5 py-3 text-sm font-medium text-zinc-200 shadow-[0_10px_40px_-12px_rgba(0,0,0,0.7)] outline-none backdrop-blur-xl focus-visible:ring-2 focus-visible:ring-lime-400/60"
|
|
99
|
+
>
|
|
100
|
+
Menu
|
|
101
|
+
{open ? <X size={18} /> : <Menu size={18} />}
|
|
102
|
+
</button>
|
|
103
|
+
<AnimatePresence>
|
|
104
|
+
{open && (
|
|
105
|
+
<motion.nav
|
|
106
|
+
aria-label="Main"
|
|
107
|
+
initial={{ opacity: 0, y: -8, scale: 0.98 }}
|
|
108
|
+
animate={{ opacity: 1, y: 0, scale: 1 }}
|
|
109
|
+
exit={{ opacity: 0, y: -6, scale: 0.98, transition: { duration: 0.15 } }}
|
|
110
|
+
transition={{ duration: 0.22, ease }}
|
|
111
|
+
className="absolute left-0 right-0 top-full z-10 mt-2 max-h-[70vh] origin-top overflow-y-auto rounded-2xl border border-white/10 bg-zinc-950/95 p-2 shadow-[0_24px_60px_-20px_rgba(0,0,0,0.8)] backdrop-blur-xl"
|
|
112
|
+
>
|
|
113
|
+
{navData.map((item, i) => {
|
|
114
|
+
const hasMenu = Boolean(item.submenu?.length);
|
|
115
|
+
const isOpen = section === i;
|
|
116
|
+
if (!hasMenu) {
|
|
117
|
+
return (
|
|
118
|
+
<a key={i} href={item.href || '#'} className="block rounded-xl px-3 py-3 text-sm font-medium text-zinc-200 outline-none hover:bg-white/[0.05] focus-visible:bg-white/[0.07]">
|
|
119
|
+
{item.label}
|
|
120
|
+
</a>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
return (
|
|
124
|
+
<div key={i}>
|
|
125
|
+
<button
|
|
126
|
+
type="button"
|
|
127
|
+
aria-expanded={isOpen}
|
|
128
|
+
onClick={() => setSection(isOpen ? null : i)}
|
|
129
|
+
className={`flex w-full items-center justify-between rounded-xl px-3 py-3 text-sm font-medium outline-none hover:bg-white/[0.05] focus-visible:bg-white/[0.07] ${isOpen ? 'text-lime-300' : 'text-zinc-200'}`}
|
|
130
|
+
>
|
|
131
|
+
{item.label}
|
|
132
|
+
<ChevronDown size={16} className={`transition-transform duration-300 ${isOpen ? 'rotate-180' : ''}`} />
|
|
133
|
+
</button>
|
|
134
|
+
<AnimatePresence initial={false}>
|
|
135
|
+
{isOpen && (
|
|
136
|
+
<motion.ul
|
|
137
|
+
initial={{ height: 0, opacity: 0 }}
|
|
138
|
+
animate={{ height: 'auto', opacity: 1 }}
|
|
139
|
+
exit={{ height: 0, opacity: 0 }}
|
|
140
|
+
transition={{ duration: 0.25, ease }}
|
|
141
|
+
className="overflow-hidden pl-2"
|
|
142
|
+
>
|
|
143
|
+
{item.submenu.map((sub, j) => (
|
|
144
|
+
<li key={j}>
|
|
145
|
+
<a href={sub.href} className="flex items-center gap-3 rounded-lg px-3 py-2 outline-none hover:bg-white/[0.05] focus-visible:bg-white/[0.07]">
|
|
146
|
+
{sub.image ? (
|
|
147
|
+
<img src={sub.image} alt="" className="h-9 w-9 shrink-0 rounded-md object-cover ring-1 ring-white/10" />
|
|
148
|
+
) : (
|
|
149
|
+
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-lime-400/20 bg-lime-400/10 text-xs font-semibold text-lime-300">
|
|
150
|
+
{sub.icon ? <sub.icon size={14} /> : sub.label.charAt(0)}
|
|
151
|
+
</span>
|
|
152
|
+
)}
|
|
153
|
+
<span className="min-w-0">
|
|
154
|
+
<span className="block truncate text-sm text-zinc-100">{sub.label}</span>
|
|
155
|
+
{sub.subtitle && <span className="block truncate text-xs text-zinc-500">{sub.subtitle}</span>}
|
|
156
|
+
</span>
|
|
157
|
+
</a>
|
|
158
|
+
</li>
|
|
159
|
+
))}
|
|
160
|
+
</motion.ul>
|
|
161
|
+
)}
|
|
162
|
+
</AnimatePresence>
|
|
163
|
+
</div>
|
|
164
|
+
);
|
|
165
|
+
})}
|
|
166
|
+
</motion.nav>
|
|
167
|
+
)}
|
|
168
|
+
</AnimatePresence>
|
|
169
|
+
</div>
|
|
170
|
+
);
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
// Below this much room the desktop bar would not fit, so the phone menu shows.
|
|
174
|
+
const COMPACT_BELOW = 640;
|
|
175
|
+
|
|
73
176
|
const NavDropDown = ({ navData = [], className = '' }) => {
|
|
177
|
+
const hostRef = useRef(null);
|
|
178
|
+
const [compact, setCompact] = useState(false);
|
|
179
|
+
useLayoutEffect(() => {
|
|
180
|
+
const host = hostRef.current?.parentElement;
|
|
181
|
+
if (!host) return undefined;
|
|
182
|
+
const check = () => setCompact(host.clientWidth < COMPACT_BELOW);
|
|
183
|
+
check();
|
|
184
|
+
const ro = new ResizeObserver(check);
|
|
185
|
+
ro.observe(host);
|
|
186
|
+
return () => ro.disconnect();
|
|
187
|
+
}, []);
|
|
188
|
+
return (
|
|
189
|
+
<div ref={hostRef} className={`flex w-full justify-center ${className}`}>
|
|
190
|
+
{compact ? <MobileNav navData={navData} /> : <DesktopNav navData={navData} />}
|
|
191
|
+
</div>
|
|
192
|
+
);
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const DesktopNav = ({ navData = [], className = '' }) => {
|
|
74
196
|
const uid = useId();
|
|
75
197
|
const rootRef = useRef(null);
|
|
76
198
|
const barRef = useRef(null);
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import React, { useEffect, useRef } from "react";
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
* Particle Text: your text rebuilt from thousands of tiny particles on a
|
|
5
|
+
* canvas. They fly in to form the words (assemble, fall, explode or wave),
|
|
6
|
+
* move out of the pointer's way, and a click sends a shockwave through them
|
|
7
|
+
* before they spring back. Moving particles light up in the accent color.
|
|
8
|
+
* When everything has settled and the pointer is away, the loop sleeps.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const TAU = Math.PI * 2;
|
|
12
|
+
|
|
13
|
+
// Points where the text is drawn, sampled from an offscreen canvas.
|
|
14
|
+
function sampleText(text, width, height, weight, family, maxSize, gap) {
|
|
15
|
+
const off = document.createElement("canvas");
|
|
16
|
+
off.width = Math.max(1, Math.floor(width));
|
|
17
|
+
off.height = Math.max(1, Math.floor(height));
|
|
18
|
+
const c = off.getContext("2d", { willReadFrequently: true });
|
|
19
|
+
let size = Math.min(maxSize, height * 0.7);
|
|
20
|
+
c.font = `${weight} ${size}px ${family}`;
|
|
21
|
+
const measured = c.measureText(text).width;
|
|
22
|
+
// Shrink to fit 90% of the width.
|
|
23
|
+
if (measured > width * 0.9) size *= (width * 0.9) / measured;
|
|
24
|
+
c.font = `${weight} ${size}px ${family}`;
|
|
25
|
+
c.textAlign = "center";
|
|
26
|
+
c.textBaseline = "middle";
|
|
27
|
+
c.fillStyle = "#fff";
|
|
28
|
+
c.fillText(text, width / 2, height / 2);
|
|
29
|
+
const data = c.getImageData(0, 0, off.width, off.height).data;
|
|
30
|
+
const pts = [];
|
|
31
|
+
for (let y = 0; y < off.height; y += gap) {
|
|
32
|
+
for (let x = 0; x < off.width; x += gap) {
|
|
33
|
+
if (data[(y * off.width + x) * 4 + 3] > 120) pts.push(x, y);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return pts;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const ParticleText = ({
|
|
40
|
+
text = "ApexUI",
|
|
41
|
+
fontSize = 9,
|
|
42
|
+
fontWeight = 800,
|
|
43
|
+
fontFamily,
|
|
44
|
+
particleSize = 2,
|
|
45
|
+
gap = 4,
|
|
46
|
+
color = "currentColor",
|
|
47
|
+
accentColor = "#a3e635",
|
|
48
|
+
intro = "assemble",
|
|
49
|
+
replayOn = "none",
|
|
50
|
+
repelRadius = 90,
|
|
51
|
+
repelStrength = 1,
|
|
52
|
+
clickBurst = true,
|
|
53
|
+
height = "20rem",
|
|
54
|
+
className = "",
|
|
55
|
+
}) => {
|
|
56
|
+
const wrap = useRef(null);
|
|
57
|
+
const canvas = useRef(null);
|
|
58
|
+
const live = useRef({ particleSize, accentColor, color, repelRadius, repelStrength, clickBurst, replayOn, intro });
|
|
59
|
+
live.current = { particleSize, accentColor, color, repelRadius, repelStrength, clickBurst, replayOn, intro };
|
|
60
|
+
const wakeRef = useRef(() => {});
|
|
61
|
+
|
|
62
|
+
// Prop changes that don't need a rebuild just wake the loop to redraw.
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
wakeRef.current();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
const el = wrap.current;
|
|
69
|
+
const cv = canvas.current;
|
|
70
|
+
const ctx = cv.getContext("2d");
|
|
71
|
+
const reduced = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
|
|
72
|
+
let P = new Float32Array(0); // per particle: x, y, vx, vy, tx, ty, delay, seed
|
|
73
|
+
let n = 0;
|
|
74
|
+
let w = 0;
|
|
75
|
+
let h = 0;
|
|
76
|
+
let dpr = 1;
|
|
77
|
+
let raf = 0;
|
|
78
|
+
let last = 0;
|
|
79
|
+
let started = 0;
|
|
80
|
+
let alive = true;
|
|
81
|
+
let visible = true;
|
|
82
|
+
let built = "";
|
|
83
|
+
const pointer = { x: -9999, y: -9999, on: false };
|
|
84
|
+
|
|
85
|
+
const startPositions = (mode) => {
|
|
86
|
+
started = performance.now();
|
|
87
|
+
for (let i = 0; i < n; i++) {
|
|
88
|
+
const o = i * 8;
|
|
89
|
+
const tx = P[o + 4];
|
|
90
|
+
const ty = P[o + 5];
|
|
91
|
+
const seed = P[o + 7];
|
|
92
|
+
let x = tx;
|
|
93
|
+
let y = ty;
|
|
94
|
+
let delay = 0;
|
|
95
|
+
if (reduced) {
|
|
96
|
+
// Appear in place.
|
|
97
|
+
} else if (mode === "fall") {
|
|
98
|
+
x = tx + (seed - 0.5) * 30;
|
|
99
|
+
y = -20 - seed * h * 0.6;
|
|
100
|
+
delay = seed * 500;
|
|
101
|
+
} else if (mode === "explode") {
|
|
102
|
+
const a = seed * TAU * 7;
|
|
103
|
+
x = w / 2 + Math.cos(a) * 4;
|
|
104
|
+
y = h / 2 + Math.sin(a) * 4;
|
|
105
|
+
delay = seed * 200;
|
|
106
|
+
} else if (mode === "wave") {
|
|
107
|
+
x = tx - w * 0.6;
|
|
108
|
+
y = ty + Math.sin(tx * 0.04) * 40;
|
|
109
|
+
delay = (tx / w) * 700;
|
|
110
|
+
} else {
|
|
111
|
+
x = Math.random() * w;
|
|
112
|
+
y = Math.random() * h;
|
|
113
|
+
delay = seed * 350;
|
|
114
|
+
}
|
|
115
|
+
P[o] = x;
|
|
116
|
+
P[o + 1] = y;
|
|
117
|
+
P[o + 2] = mode === "explode" && !reduced ? (Math.random() - 0.5) * 900 : 0;
|
|
118
|
+
P[o + 3] = mode === "explode" && !reduced ? (Math.random() - 0.5) * 900 : 0;
|
|
119
|
+
P[o + 6] = delay;
|
|
120
|
+
}
|
|
121
|
+
wake();
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const build = (withIntro) => {
|
|
125
|
+
const r = el.getBoundingClientRect();
|
|
126
|
+
if (!r.width || !r.height) return;
|
|
127
|
+
dpr = Math.min(window.devicePixelRatio || 1, 2);
|
|
128
|
+
w = r.width;
|
|
129
|
+
h = r.height;
|
|
130
|
+
cv.width = Math.round(w * dpr);
|
|
131
|
+
cv.height = Math.round(h * dpr);
|
|
132
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
133
|
+
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
|
|
134
|
+
const family = fontFamily || getComputedStyle(el).fontFamily || "sans-serif";
|
|
135
|
+
const pts = sampleText(String(text || " "), w, h, fontWeight, family, fontSize * rem, Math.max(2, Math.round(gap)));
|
|
136
|
+
// Cap the count so huge containers stay smooth.
|
|
137
|
+
const cap = 6000;
|
|
138
|
+
const stride = Math.max(1, Math.ceil(pts.length / 2 / cap));
|
|
139
|
+
n = Math.floor(pts.length / 2 / stride);
|
|
140
|
+
const old = P;
|
|
141
|
+
const oldN = old.length / 8;
|
|
142
|
+
P = new Float32Array(n * 8);
|
|
143
|
+
for (let i = 0; i < n; i++) {
|
|
144
|
+
const o = i * 8;
|
|
145
|
+
const s = i * stride * 2;
|
|
146
|
+
P[o + 4] = pts[s];
|
|
147
|
+
P[o + 5] = pts[s + 1];
|
|
148
|
+
P[o + 7] = ((i * 9301 + 49297) % 233280) / 233280;
|
|
149
|
+
// Keep current positions on resize so nothing jumps.
|
|
150
|
+
if (!withIntro && i < oldN) {
|
|
151
|
+
P[o] = old[i * 8];
|
|
152
|
+
P[o + 1] = old[i * 8 + 1];
|
|
153
|
+
} else {
|
|
154
|
+
P[o] = pts[s];
|
|
155
|
+
P[o + 1] = pts[s + 1];
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (withIntro) startPositions(live.current.intro);
|
|
159
|
+
wake();
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const toRgb = (() => {
|
|
163
|
+
const c = document.createElement("canvas").getContext("2d");
|
|
164
|
+
return (value) => {
|
|
165
|
+
c.fillStyle = "#000";
|
|
166
|
+
c.fillStyle = value;
|
|
167
|
+
const hex = c.fillStyle;
|
|
168
|
+
if (hex.startsWith("#")) return [parseInt(hex.slice(1, 3), 16), parseInt(hex.slice(3, 5), 16), parseInt(hex.slice(5, 7), 16)];
|
|
169
|
+
const m = hex.match(/\d+(\.\d+)?/g) || [0, 0, 0];
|
|
170
|
+
return [+m[0], +m[1], +m[2]];
|
|
171
|
+
};
|
|
172
|
+
})();
|
|
173
|
+
|
|
174
|
+
const frame = (now) => {
|
|
175
|
+
raf = 0;
|
|
176
|
+
if (!alive) return;
|
|
177
|
+
const o = live.current;
|
|
178
|
+
const dt = last ? Math.min(0.033, (now - last) / 1000) : 1 / 60;
|
|
179
|
+
last = now;
|
|
180
|
+
const elapsed = now - started;
|
|
181
|
+
const base = o.color === "currentColor" ? getComputedStyle(el).color : o.color;
|
|
182
|
+
const [br, bg, bb] = toRgb(base);
|
|
183
|
+
const [ar, ag, ab] = toRgb(o.accentColor);
|
|
184
|
+
const size = Math.max(0.5, o.particleSize);
|
|
185
|
+
const rr = o.repelRadius;
|
|
186
|
+
const rs = o.repelStrength * 2600;
|
|
187
|
+
let busy = pointer.on;
|
|
188
|
+
|
|
189
|
+
ctx.clearRect(0, 0, w, h);
|
|
190
|
+
// Two passes: settled particles in the base color, moving ones in the accent.
|
|
191
|
+
const hot = [];
|
|
192
|
+
ctx.fillStyle = `rgb(${br}, ${bg}, ${bb})`;
|
|
193
|
+
ctx.beginPath();
|
|
194
|
+
for (let i = 0; i < n; i++) {
|
|
195
|
+
const k = i * 8;
|
|
196
|
+
let x = P[k];
|
|
197
|
+
let y = P[k + 1];
|
|
198
|
+
let vx = P[k + 2];
|
|
199
|
+
let vy = P[k + 3];
|
|
200
|
+
if (elapsed >= P[k + 6]) {
|
|
201
|
+
// Spring toward the target, with damping.
|
|
202
|
+
vx += (P[k + 4] - x) * 60 * dt;
|
|
203
|
+
vy += (P[k + 5] - y) * 60 * dt;
|
|
204
|
+
if (pointer.on && rr > 0) {
|
|
205
|
+
const dx = x - pointer.x;
|
|
206
|
+
const dy = y - pointer.y;
|
|
207
|
+
const d2 = dx * dx + dy * dy;
|
|
208
|
+
if (d2 < rr * rr && d2 > 0.01) {
|
|
209
|
+
const d = Math.sqrt(d2);
|
|
210
|
+
const f = (1 - d / rr) * rs * dt;
|
|
211
|
+
vx += (dx / d) * f;
|
|
212
|
+
vy += (dy / d) * f;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
const damp = Math.pow(0.0009, dt);
|
|
216
|
+
vx *= damp;
|
|
217
|
+
vy *= damp;
|
|
218
|
+
x += vx * dt;
|
|
219
|
+
y += vy * dt;
|
|
220
|
+
}
|
|
221
|
+
P[k] = x;
|
|
222
|
+
P[k + 1] = y;
|
|
223
|
+
P[k + 2] = vx;
|
|
224
|
+
P[k + 3] = vy;
|
|
225
|
+
const off = Math.abs(P[k + 4] - x) + Math.abs(P[k + 5] - y);
|
|
226
|
+
if (off > 0.3 || Math.abs(vx) + Math.abs(vy) > 2) busy = true;
|
|
227
|
+
if (off > 4) hot.push(x, y, Math.min(1, off / 40));
|
|
228
|
+
else ctx.rect(x - size / 2, y - size / 2, size, size);
|
|
229
|
+
}
|
|
230
|
+
ctx.fill();
|
|
231
|
+
if (hot.length) {
|
|
232
|
+
ctx.globalCompositeOperation = "lighter";
|
|
233
|
+
for (let j = 0; j < hot.length; j += 3) {
|
|
234
|
+
const t = hot[j + 2];
|
|
235
|
+
ctx.fillStyle = `rgb(${Math.round(br + (ar - br) * t)}, ${Math.round(bg + (ag - bg) * t)}, ${Math.round(bb + (ab - bb) * t)})`;
|
|
236
|
+
ctx.fillRect(hot[j] - size / 2, hot[j + 1] - size / 2, size, size);
|
|
237
|
+
}
|
|
238
|
+
ctx.globalCompositeOperation = "source-over";
|
|
239
|
+
}
|
|
240
|
+
if (busy && visible) raf = requestAnimationFrame(frame);
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
function wake() {
|
|
244
|
+
if (raf || !alive || !visible) return;
|
|
245
|
+
last = 0;
|
|
246
|
+
raf = requestAnimationFrame(frame);
|
|
247
|
+
}
|
|
248
|
+
wakeRef.current = wake;
|
|
249
|
+
|
|
250
|
+
const local = (e) => {
|
|
251
|
+
const r = cv.getBoundingClientRect();
|
|
252
|
+
return [e.clientX - r.left, e.clientY - r.top];
|
|
253
|
+
};
|
|
254
|
+
const onMove = (e) => {
|
|
255
|
+
[pointer.x, pointer.y] = local(e);
|
|
256
|
+
pointer.on = true;
|
|
257
|
+
wake();
|
|
258
|
+
};
|
|
259
|
+
const onEnter = (e) => {
|
|
260
|
+
onMove(e);
|
|
261
|
+
if (live.current.replayOn === "hover") startPositions(live.current.intro);
|
|
262
|
+
};
|
|
263
|
+
const onLeave = () => {
|
|
264
|
+
pointer.on = false;
|
|
265
|
+
pointer.x = pointer.y = -9999;
|
|
266
|
+
wake();
|
|
267
|
+
};
|
|
268
|
+
const onDown = (e) => {
|
|
269
|
+
const o = live.current;
|
|
270
|
+
if (o.replayOn === "click") return startPositions(o.intro);
|
|
271
|
+
if (!o.clickBurst || reduced) return;
|
|
272
|
+
const [cx, cy] = local(e);
|
|
273
|
+
for (let i = 0; i < n; i++) {
|
|
274
|
+
const k = i * 8;
|
|
275
|
+
const dx = P[k] - cx;
|
|
276
|
+
const dy = P[k + 1] - cy;
|
|
277
|
+
const d = Math.hypot(dx, dy) || 1;
|
|
278
|
+
const f = Math.max(0, 1 - d / 260) * 1400 * (0.6 + P[k + 7] * 0.8);
|
|
279
|
+
P[k + 2] += (dx / d) * f;
|
|
280
|
+
P[k + 3] += (dy / d) * f;
|
|
281
|
+
}
|
|
282
|
+
wake();
|
|
283
|
+
};
|
|
284
|
+
cv.addEventListener("pointerenter", onEnter);
|
|
285
|
+
cv.addEventListener("pointermove", onMove);
|
|
286
|
+
cv.addEventListener("pointerleave", onLeave);
|
|
287
|
+
cv.addEventListener("pointerdown", onDown);
|
|
288
|
+
|
|
289
|
+
const ro = new ResizeObserver(() => {
|
|
290
|
+
const r = el.getBoundingClientRect();
|
|
291
|
+
const key = `${Math.round(r.width)}x${Math.round(r.height)}`;
|
|
292
|
+
if (key === built) return;
|
|
293
|
+
const first = !built;
|
|
294
|
+
built = key;
|
|
295
|
+
build(first);
|
|
296
|
+
});
|
|
297
|
+
ro.observe(el);
|
|
298
|
+
const io = new IntersectionObserver(([entry]) => {
|
|
299
|
+
visible = entry.isIntersecting;
|
|
300
|
+
wake();
|
|
301
|
+
});
|
|
302
|
+
io.observe(el);
|
|
303
|
+
// Rebuild once web fonts are ready so the shape uses the real font.
|
|
304
|
+
document.fonts?.ready.then(() => alive && built && build(false));
|
|
305
|
+
|
|
306
|
+
return () => {
|
|
307
|
+
alive = false;
|
|
308
|
+
wakeRef.current = () => {};
|
|
309
|
+
if (raf) cancelAnimationFrame(raf);
|
|
310
|
+
ro.disconnect();
|
|
311
|
+
io.disconnect();
|
|
312
|
+
cv.removeEventListener("pointerenter", onEnter);
|
|
313
|
+
cv.removeEventListener("pointermove", onMove);
|
|
314
|
+
cv.removeEventListener("pointerleave", onLeave);
|
|
315
|
+
cv.removeEventListener("pointerdown", onDown);
|
|
316
|
+
};
|
|
317
|
+
// Text and font settings rebuild the particles; the rest is read live.
|
|
318
|
+
}, [text, fontSize, fontWeight, fontFamily, gap]);
|
|
319
|
+
|
|
320
|
+
return (
|
|
321
|
+
<div ref={wrap} className={`relative w-full touch-pan-y select-none overflow-hidden ${className}`} style={{ height }}>
|
|
322
|
+
<canvas ref={canvas} aria-hidden="true" className="absolute inset-0 block h-full w-full" />
|
|
323
|
+
<span className="sr-only">{text}</span>
|
|
324
|
+
</div>
|
|
325
|
+
);
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
export default ParticleText;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import React, { useEffect, useId, useRef, useState } from "react";
|
|
2
|
+
import { AnimatePresence, motion } from "framer-motion";
|
|
3
|
+
import { Camera, Heart, Home, MessageCircle, Plus, Settings, Share2, User } from "lucide-react";
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
* Radial Menu: a round button that fans its actions out in a circle (or an
|
|
7
|
+
* arc). Items spring out one after another and tuck back in on close.
|
|
8
|
+
* Keyboard friendly: arrows move between items, Escape closes, and clicking
|
|
9
|
+
* outside closes too.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const DEFAULT_ITEMS = [
|
|
13
|
+
{ icon: Home, label: "Home" },
|
|
14
|
+
{ icon: User, label: "Profile" },
|
|
15
|
+
{ icon: MessageCircle, label: "Messages" },
|
|
16
|
+
{ icon: Camera, label: "Camera" },
|
|
17
|
+
{ icon: Heart, label: "Likes" },
|
|
18
|
+
{ icon: Share2, label: "Share" },
|
|
19
|
+
{ icon: Settings, label: "Settings" },
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
const RadialMenu = ({ items = DEFAULT_ITEMS, radius = 6.5, arc = 360, startAngle = -90, accentColor = "#a3e635", theme = "dark", onSelect, className = "" }) => {
|
|
23
|
+
const id = useId();
|
|
24
|
+
const [open, setOpen] = useState(false);
|
|
25
|
+
const [picked, setPicked] = useState(null);
|
|
26
|
+
const root = useRef(null);
|
|
27
|
+
const btns = useRef([]);
|
|
28
|
+
const dark = theme !== "light";
|
|
29
|
+
const n = items.length;
|
|
30
|
+
// Full circle spreads evenly; a partial arc includes both ends.
|
|
31
|
+
const step = arc >= 360 ? 360 / n : arc / Math.max(1, n - 1);
|
|
32
|
+
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
if (!open) return undefined;
|
|
35
|
+
const down = (e) => !root.current?.contains(e.target) && setOpen(false);
|
|
36
|
+
const key = (e) => e.key === "Escape" && setOpen(false);
|
|
37
|
+
document.addEventListener("pointerdown", down);
|
|
38
|
+
document.addEventListener("keydown", key);
|
|
39
|
+
return () => {
|
|
40
|
+
document.removeEventListener("pointerdown", down);
|
|
41
|
+
document.removeEventListener("keydown", key);
|
|
42
|
+
};
|
|
43
|
+
}, [open]);
|
|
44
|
+
|
|
45
|
+
const choose = (item, i) => {
|
|
46
|
+
setPicked(i);
|
|
47
|
+
setOpen(false);
|
|
48
|
+
onSelect?.(item, i);
|
|
49
|
+
};
|
|
50
|
+
const onItemKey = (e, i) => {
|
|
51
|
+
const next = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }[e.key];
|
|
52
|
+
if (!next) return;
|
|
53
|
+
e.preventDefault();
|
|
54
|
+
btns.current[(i + next + n) % n]?.focus();
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<div ref={root} className={`relative inline-grid place-items-center ${className}`} style={{ width: `${radius * 2 + 4}rem`, height: `${radius * 2 + 4}rem`, maxWidth: "100%" }}>
|
|
59
|
+
<AnimatePresence>
|
|
60
|
+
{open &&
|
|
61
|
+
items.map((item, i) => {
|
|
62
|
+
const a = ((startAngle + i * step) * Math.PI) / 180;
|
|
63
|
+
const Icon = item.icon;
|
|
64
|
+
return (
|
|
65
|
+
<motion.button
|
|
66
|
+
key={`${id}-${i}`}
|
|
67
|
+
ref={(el) => (btns.current[i] = el)}
|
|
68
|
+
type="button"
|
|
69
|
+
aria-label={item.label}
|
|
70
|
+
title={item.label}
|
|
71
|
+
onClick={() => choose(item, i)}
|
|
72
|
+
onKeyDown={(e) => onItemKey(e, i)}
|
|
73
|
+
className={`group absolute grid h-12 w-12 place-items-center rounded-full shadow-lg outline-none ring-1 focus-visible:ring-2 ${dark ? "bg-neutral-900 text-white ring-white/10 hover:bg-neutral-800 focus-visible:ring-white/50" : "bg-white text-neutral-800 ring-black/10 hover:bg-neutral-50 focus-visible:ring-black/40"}`}
|
|
74
|
+
initial={{ x: 0, y: 0, scale: 0.3, opacity: 0 }}
|
|
75
|
+
animate={{ x: `${Math.cos(a) * radius}rem`, y: `${Math.sin(a) * radius}rem`, scale: 1, opacity: 1, transition: { type: "spring", stiffness: 420, damping: 24, delay: i * 0.03 } }}
|
|
76
|
+
exit={{ x: 0, y: 0, scale: 0.3, opacity: 0, transition: { duration: 0.18, delay: (n - 1 - i) * 0.015 } }}
|
|
77
|
+
style={picked === i ? { color: accentColor } : undefined}
|
|
78
|
+
>
|
|
79
|
+
{Icon ? <Icon className="h-5 w-5" /> : item.label[0]}
|
|
80
|
+
<span className={`pointer-events-none absolute top-full mt-1.5 whitespace-nowrap rounded-md px-1.5 py-0.5 text-[0.65rem] font-medium opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100 ${dark ? "bg-white text-black" : "bg-neutral-900 text-white"}`}>{item.label}</span>
|
|
81
|
+
</motion.button>
|
|
82
|
+
);
|
|
83
|
+
})}
|
|
84
|
+
</AnimatePresence>
|
|
85
|
+
<motion.button
|
|
86
|
+
type="button"
|
|
87
|
+
aria-expanded={open}
|
|
88
|
+
aria-label={open ? "Close menu" : "Open menu"}
|
|
89
|
+
onClick={() => setOpen((o) => !o)}
|
|
90
|
+
onKeyDown={(e) => open && e.key === "ArrowRight" && (e.preventDefault(), btns.current[0]?.focus())}
|
|
91
|
+
whileTap={{ scale: 0.92 }}
|
|
92
|
+
className="relative z-10 grid h-16 w-16 place-items-center rounded-full text-black shadow-[0_10px_30px_-8px_rgba(0,0,0,0.5)] outline-none focus-visible:ring-4 focus-visible:ring-white/40"
|
|
93
|
+
style={{ background: accentColor }}
|
|
94
|
+
>
|
|
95
|
+
<motion.span animate={{ rotate: open ? 135 : 0 }} transition={{ type: "spring", stiffness: 400, damping: 22 }}>
|
|
96
|
+
<Plus className="h-7 w-7" strokeWidth={2.5} />
|
|
97
|
+
</motion.span>
|
|
98
|
+
</motion.button>
|
|
99
|
+
</div>
|
|
100
|
+
);
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
export default RadialMenu;
|