apex-ui-kit 2.12.1 → 2.13.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 +17 -5
- package/dist/ApexUI-Kit/templates/FloatingNavbar/FloatingNavbar.jsx +36 -11
- package/dist/ApexUI-Kit/templates/GooeyNav/GooeyNav.jsx +4 -6
- package/dist/ApexUI-Kit/templates/NavMenu/NavMenu.jsx +5 -8
- package/dist/ApexUI-Kit/templates/ParallaxCarousel/ParallaxCarousel.jsx +158 -153
- package/dist/ApexUI-Kit/templates/ThemeSwitcher/ThemeSwitcher.jsx +24 -8
- package/dist/cli/index.js +86 -1
- package/dist/skill/apexui/SKILL.md +91 -0
- package/dist/skill/apexui/references/checklist.md +37 -0
- package/dist/skill/apexui/references/components.md +174 -0
- package/dist/skill/apexui/references/design-system.md +104 -0
- package/dist/skill/apexui/references/recipes.md +68 -0
- package/dist/skill/apexui/references/responsive-mobile.md +50 -0
- package/dist/skill/apexui/references/security.md +81 -0
- package/package.json +7 -4
- package/dist/ApexUI-Kit/templates/CardSwap3D/CardSwap3D.jsx +0 -104
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
|
+
**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
|
-
- **
|
|
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.
|
|
@@ -65,6 +65,17 @@ export default function App() {
|
|
|
65
65
|
}
|
|
66
66
|
```
|
|
67
67
|
|
|
68
|
+
## AI agent skill
|
|
69
|
+
|
|
70
|
+
Teach your AI agent (Claude Code, Antigravity, Cursor, Windsurf, Copilot, Codex, Gemini CLI) to build premium, responsive and secure sites and mobile web apps with ApexUI:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
npx apex-ui-kit skill # pick your agents
|
|
74
|
+
npx apex-ui-kit skill --ai all # or install for every agent at once
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
It installs a skill with every component and its CLI name, page and app-screen recipes, a design system (palettes, fonts, motion), responsive and mobile rules, a security checklist and a ship checklist. For ChatGPT or Claude.ai, download the all-in-one file from [apex-ui.in/skills](https://apex-ui.in/skills).
|
|
78
|
+
|
|
68
79
|
## Example: AI prompt with a model picker
|
|
69
80
|
|
|
70
81
|
```bash
|
|
@@ -127,7 +138,6 @@ Every component's props, variants and copy-ready code are on its docs page.
|
|
|
127
138
|
| --- | --- |
|
|
128
139
|
| [Bounce Cards](https://apex-ui.in/components/bounce-cards) | `bounce-cards` |
|
|
129
140
|
| [Card Stack](https://apex-ui.in/components/card-stack) | `card-stack` |
|
|
130
|
-
| [Card Swap 3D](https://apex-ui.in/components/card-swap-3d) | `card-swap-3d` |
|
|
131
141
|
| [Cards](https://apex-ui.in/components/cards) | `basic-card`, `meteor-card` |
|
|
132
142
|
| [Distortion Image](https://apex-ui.in/components/distortion-image) | `distortion-image` |
|
|
133
143
|
| [Folder](https://apex-ui.in/components/folder) | `folder` |
|
|
@@ -261,13 +271,15 @@ Every component's props, variants and copy-ready code are on its docs page.
|
|
|
261
271
|
| [Dot Globe](https://apex-ui.in/components/dot-globe) | `dot-globe` |
|
|
262
272
|
| [World Map](https://apex-ui.in/components/world-map) | `world-map` |
|
|
263
273
|
|
|
264
|
-
## What's new in 2.
|
|
274
|
+
## What's new in 2.13
|
|
265
275
|
|
|
276
|
+
- New in 2.13: `npx apex-ui-kit skill` installs the ApexUI AI agent skill for Claude Code, Antigravity, Cursor, Windsurf, Copilot, Codex and Gemini CLI.
|
|
277
|
+
- New in 2.12: components size from their own container instead of the screen (Testimonials, Animated Tabs, Gooey Nav, Nav Menu, Flip Clock, Image Accordion, Beam Bar), a smoother Floating Navbar menu, no flicker in Theme Switcher and Parallax Carousel. Smart Grid Card and Card Swap 3D were removed.
|
|
266
278
|
- New in 2.10: Fluid Cursor (a real-time WebGL fluid sim that trails the cursor) and Sketch Reveal (a photo turns into a glowing sketch and burns away into the next one as you scroll).
|
|
267
279
|
- 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.
|
|
268
280
|
- 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).
|
|
269
281
|
- 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.
|
|
270
|
-
- 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,
|
|
282
|
+
- 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, 3D Text Ring, Elastic Slider, Stepper and Pixel Transition.
|
|
271
283
|
- 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.
|
|
272
284
|
- AI Model Prompt now exports `DEFAULT_LLM_MODELS` and an index file, so you can import both from the folder.
|
|
273
285
|
- 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.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useEffect, useId, useState } from "react";
|
|
1
|
+
import React, { useEffect, useId, useRef, useState } from "react";
|
|
2
2
|
import { AnimatePresence, motion } from "framer-motion";
|
|
3
3
|
import { ArrowUpRight } from "lucide-react";
|
|
4
4
|
|
|
@@ -18,12 +18,14 @@ const DEFAULT_LINKS = [
|
|
|
18
18
|
{ label: "Blog", href: "#blog" },
|
|
19
19
|
];
|
|
20
20
|
|
|
21
|
+
// Three lines that fold into an X. Transforms only, so it stays smooth on phones.
|
|
21
22
|
function Burger({ open }) {
|
|
23
|
+
const spring = { type: "spring", stiffness: 500, damping: 32 };
|
|
22
24
|
return (
|
|
23
25
|
<span className="relative block h-3.5 w-5" aria-hidden="true">
|
|
24
|
-
<motion.span className="absolute left-0 top-
|
|
25
|
-
<motion.span className="absolute left-0 top-
|
|
26
|
-
<motion.span className="absolute
|
|
26
|
+
<motion.span className="absolute left-0 top-[6px] h-[2px] w-full rounded-full bg-current" initial={false} animate={{ y: open ? 0 : -6, rotate: open ? 45 : 0 }} transition={spring} />
|
|
27
|
+
<motion.span className="absolute left-0 top-[6px] h-[2px] w-full rounded-full bg-current" initial={false} animate={{ opacity: open ? 0 : 1, scaleX: open ? 0.3 : 1 }} transition={{ duration: 0.15 }} />
|
|
28
|
+
<motion.span className="absolute left-0 top-[6px] h-[2px] w-full rounded-full bg-current" initial={false} animate={{ y: open ? 0 : 6, rotate: open ? -45 : 0 }} transition={spring} />
|
|
27
29
|
</span>
|
|
28
30
|
);
|
|
29
31
|
}
|
|
@@ -63,6 +65,20 @@ const FloatingNavbar = ({
|
|
|
63
65
|
return () => root.removeEventListener("scroll", onScroll);
|
|
64
66
|
}, [hideOnScroll, scrollRoot]);
|
|
65
67
|
|
|
68
|
+
// Close the menu with Escape or a tap outside it.
|
|
69
|
+
const wrap = useRef(null);
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
if (!open) return undefined;
|
|
72
|
+
const onKey = (e) => e.key === "Escape" && setOpen(false);
|
|
73
|
+
const onDown = (e) => !wrap.current?.contains(e.target) && setOpen(false);
|
|
74
|
+
document.addEventListener("keydown", onKey);
|
|
75
|
+
document.addEventListener("pointerdown", onDown);
|
|
76
|
+
return () => {
|
|
77
|
+
document.removeEventListener("keydown", onKey);
|
|
78
|
+
document.removeEventListener("pointerdown", onDown);
|
|
79
|
+
};
|
|
80
|
+
}, [open]);
|
|
81
|
+
|
|
66
82
|
const go = (link, e) => {
|
|
67
83
|
setActive(link.label);
|
|
68
84
|
setOpen(false);
|
|
@@ -70,16 +86,18 @@ const FloatingNavbar = ({
|
|
|
70
86
|
};
|
|
71
87
|
|
|
72
88
|
const surface = dark ? "border-white/10 bg-neutral-900/70 text-white shadow-[0_10px_40px_-12px_rgba(0,0,0,0.7)]" : "border-black/10 bg-white/75 text-neutral-900 shadow-[0_10px_40px_-14px_rgba(0,0,0,0.25)]";
|
|
89
|
+
// Solid: a blur nested inside the blurred bar cannot see the page behind it.
|
|
90
|
+
const menuSurface = dark ? "border-white/10 bg-neutral-900 text-white shadow-[0_18px_50px_-16px_rgba(0,0,0,0.8)]" : "border-black/10 bg-white text-neutral-900 shadow-[0_18px_50px_-18px_rgba(0,0,0,0.3)]";
|
|
73
91
|
const muted = dark ? "text-white/65 hover:text-white" : "text-neutral-500 hover:text-neutral-900";
|
|
74
92
|
|
|
75
93
|
return (
|
|
76
|
-
<div className={`@container sticky top-3 z-50 w-full px-3 ${className}`}>
|
|
94
|
+
<div ref={wrap} className={`@container sticky top-3 z-50 w-full px-3 ${className}`}>
|
|
77
95
|
<motion.nav
|
|
78
96
|
aria-label="Main"
|
|
79
97
|
initial={false}
|
|
80
98
|
animate={{ y: hidden && !open ? "-140%" : "0%", opacity: hidden && !open ? 0 : 1 }}
|
|
81
99
|
transition={{ type: "spring", stiffness: 380, damping: 34 }}
|
|
82
|
-
className={`mx-auto max-w-4xl rounded-[1.6rem] border backdrop-blur-xl transition-[padding] duration-300 ${surface} ${compact ? "px-2.5 py-1.5" : "px-3 py-2.5"}`}
|
|
100
|
+
className={`relative mx-auto max-w-4xl rounded-[1.6rem] border backdrop-blur-xl transition-[padding] duration-300 ${surface} ${compact ? "px-2.5 py-1.5" : "px-3 py-2.5"}`}
|
|
83
101
|
>
|
|
84
102
|
<div className="flex items-center justify-between gap-3">
|
|
85
103
|
<a href="#home" onClick={(e) => go(links[0], e)} className="flex items-center gap-2 pl-1.5 text-[0.95rem] font-bold">
|
|
@@ -130,13 +148,20 @@ const FloatingNavbar = ({
|
|
|
130
148
|
</div>
|
|
131
149
|
</div>
|
|
132
150
|
|
|
133
|
-
{/* Mobile menu
|
|
151
|
+
{/* Mobile menu: its own panel under the bar that only fades and slides
|
|
152
|
+
(no height animation, so nothing re-lays out or re-blurs each frame). */}
|
|
134
153
|
<AnimatePresence initial={false}>
|
|
135
154
|
{open && (
|
|
136
|
-
<motion.div
|
|
137
|
-
|
|
155
|
+
<motion.div
|
|
156
|
+
initial={{ opacity: 0, y: -8, scale: 0.98 }}
|
|
157
|
+
animate={{ opacity: 1, y: 0, scale: 1 }}
|
|
158
|
+
exit={{ opacity: 0, y: -8, scale: 0.98 }}
|
|
159
|
+
transition={{ duration: 0.22, ease: [0.22, 1, 0.36, 1] }}
|
|
160
|
+
className={`absolute inset-x-0 top-full mt-2 origin-top rounded-[1.4rem] border will-change-transform @xl:hidden ${menuSurface}`}
|
|
161
|
+
>
|
|
162
|
+
<ul className="space-y-1 p-2">
|
|
138
163
|
{links.map((link, i) => (
|
|
139
|
-
<motion.li key={link.label} initial={{ x: -
|
|
164
|
+
<motion.li key={link.label} initial={{ x: -10, opacity: 0 }} animate={{ x: 0, opacity: 1 }} transition={{ delay: 0.03 * i + 0.04, duration: 0.25 }}>
|
|
140
165
|
<a href={link.href} onClick={(e) => go(link, e)} className={`flex items-center justify-between rounded-2xl px-4 py-3 text-[0.95rem] font-medium ${active === link.label ? (dark ? "bg-white/[0.08]" : "bg-black/[0.05]") : ""}`}>
|
|
141
166
|
{link.label}
|
|
142
167
|
{active === link.label && <span className="h-1.5 w-1.5 rounded-full" style={{ background: accentColor }} />}
|
|
@@ -144,7 +169,7 @@ const FloatingNavbar = ({
|
|
|
144
169
|
</motion.li>
|
|
145
170
|
))}
|
|
146
171
|
{cta && (
|
|
147
|
-
<motion.li initial={{ y: 8, opacity: 0 }} animate={{ y: 0, opacity: 1 }} transition={{ delay: 0.
|
|
172
|
+
<motion.li initial={{ y: 8, opacity: 0 }} animate={{ y: 0, opacity: 1 }} transition={{ delay: 0.03 * links.length + 0.06, duration: 0.25 }} className="pt-1">
|
|
148
173
|
<a href={cta.href} className="flex items-center justify-center gap-1 rounded-2xl py-3 text-[0.95rem] font-semibold text-black" style={{ background: accentColor }}>
|
|
149
174
|
{cta.label} <ArrowUpRight className="h-4 w-4" />
|
|
150
175
|
</a>
|
|
@@ -149,9 +149,8 @@ const GooeyNav = ({ items = DEFAULT_ITEMS, initialActive = 0, onChange, accentCo
|
|
|
149
149
|
const el = links.current[i];
|
|
150
150
|
const b = bar.current;
|
|
151
151
|
if (!el || !b) return null;
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
return { l: a.left - p.left + PAD, r: a.right - p.left + PAD, t: a.top - p.top + PAD, h: a.height };
|
|
152
|
+
// Layout offsets (not screen rects), so it stays right inside zoomed or scaled parents.
|
|
153
|
+
return { l: el.offsetLeft + PAD, r: el.offsetLeft + el.offsetWidth + PAD, t: el.offsetTop + PAD, h: el.offsetHeight };
|
|
155
154
|
};
|
|
156
155
|
|
|
157
156
|
// Set up GL + simulation once.
|
|
@@ -177,10 +176,9 @@ const GooeyNav = ({ items = DEFAULT_ITEMS, initialActive = 0, onChange, accentCo
|
|
|
177
176
|
// Hold the element itself: the ref is cleared on unmount before cleanup runs.
|
|
178
177
|
const barEl = bar.current;
|
|
179
178
|
const resize = () => {
|
|
180
|
-
const r = barEl.getBoundingClientRect();
|
|
181
179
|
s.dpr = Math.min(window.devicePixelRatio || 1, 2);
|
|
182
|
-
const w =
|
|
183
|
-
const h =
|
|
180
|
+
const w = barEl.offsetWidth + PAD * 2;
|
|
181
|
+
const h = barEl.offsetHeight + PAD * 2;
|
|
184
182
|
cv.width = Math.round(w * s.dpr);
|
|
185
183
|
cv.height = Math.round(h * s.dpr);
|
|
186
184
|
cv.style.width = `${w}px`;
|
|
@@ -36,10 +36,9 @@ const NavMenu = ({
|
|
|
36
36
|
};
|
|
37
37
|
|
|
38
38
|
const moveWithGSAP = () => {
|
|
39
|
-
|
|
40
|
-
const
|
|
41
|
-
const
|
|
42
|
-
const width = rect.width;
|
|
39
|
+
// Layout offsets (not screen rects), so it stays right inside zoomed or scaled parents.
|
|
40
|
+
const left = el.offsetLeft;
|
|
41
|
+
const width = el.offsetWidth;
|
|
43
42
|
|
|
44
43
|
gsap.to(indicator, {
|
|
45
44
|
x: left,
|
|
@@ -59,10 +58,8 @@ const NavMenu = ({
|
|
|
59
58
|
const handleResize = () => {
|
|
60
59
|
const el = navItemsRef.current[activeIdx];
|
|
61
60
|
if (el) {
|
|
62
|
-
const
|
|
63
|
-
const
|
|
64
|
-
const left = rect.left - parentRect.left;
|
|
65
|
-
const width = rect.width;
|
|
61
|
+
const left = el.offsetLeft;
|
|
62
|
+
const width = el.offsetWidth;
|
|
66
63
|
|
|
67
64
|
gsap.set(indicatorRef.current, {
|
|
68
65
|
x: left,
|
|
@@ -1,154 +1,159 @@
|
|
|
1
|
-
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
const
|
|
6
|
-
const
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
useEffect(() => {
|
|
26
|
-
if (isScrolling) {
|
|
27
|
-
x.set(0);
|
|
28
|
-
y.set(0);
|
|
29
|
-
}
|
|
30
|
-
}, [isScrolling, x, y]);
|
|
31
|
-
|
|
32
|
-
return (
|
|
33
|
-
<motion.div
|
|
34
|
-
ref={cardRef}
|
|
35
|
-
className="w-full h-full bg-slate-900 rounded-2xl group"
|
|
36
|
-
style={{
|
|
37
|
-
transformStyle: 'preserve-3d',
|
|
38
|
-
perspective: '1500px',
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
<img src={slide.
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
<
|
|
53
|
-
<p className="text-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
animatingRef.current
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
animatingRef.current
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
scrollTimeoutRef.current
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
}
|
|
153
|
-
|
|
1
|
+
import React, { useState, useEffect, useRef } from 'react';
|
|
2
|
+
import { motion, useMotionValue, useTransform } from 'framer-motion';
|
|
3
|
+
import { ArrowLeft, ArrowRight } from 'lucide-react';
|
|
4
|
+
const CarouselCard = ({ slide, isCenter, isScrolling }) => {
|
|
5
|
+
const cardRef = useRef(null);
|
|
6
|
+
const x = useMotionValue(0);
|
|
7
|
+
const y = useMotionValue(0);
|
|
8
|
+
|
|
9
|
+
const rotateX = useTransform(y, [-200, 200], [15, -15]);
|
|
10
|
+
const rotateY = useTransform(x, [-200, 200], [-15, 15]);
|
|
11
|
+
|
|
12
|
+
const handleMouseMove = (event) => {
|
|
13
|
+
if (!cardRef.current || !isCenter || isScrolling) return;
|
|
14
|
+
const rect = cardRef.current.getBoundingClientRect();
|
|
15
|
+
x.set(event.clientX - rect.left - rect.width / 2);
|
|
16
|
+
y.set(event.clientY - rect.top - rect.height / 2);
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const handleMouseLeave = () => {
|
|
20
|
+
x.set(0);
|
|
21
|
+
y.set(0);
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// Level the card when it leaves the centre or the page scrolls.
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
if (isScrolling || !isCenter) {
|
|
27
|
+
x.set(0);
|
|
28
|
+
y.set(0);
|
|
29
|
+
}
|
|
30
|
+
}, [isScrolling, isCenter, x, y]);
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<motion.div
|
|
34
|
+
ref={cardRef}
|
|
35
|
+
className="w-full h-full bg-slate-900 rounded-2xl group"
|
|
36
|
+
style={{
|
|
37
|
+
transformStyle: 'preserve-3d',
|
|
38
|
+
perspective: '1500px',
|
|
39
|
+
// Always the motion values (swapping them for 0 made the card jump).
|
|
40
|
+
rotateX,
|
|
41
|
+
rotateY,
|
|
42
|
+
}}
|
|
43
|
+
onMouseMove={handleMouseMove}
|
|
44
|
+
onMouseLeave={handleMouseLeave}
|
|
45
|
+
>
|
|
46
|
+
{/* Slide Content */}
|
|
47
|
+
<div className="absolute inset-0 w-full h-full" style={{ transformStyle: 'preserve-3d', perspective: '1500px' }}>
|
|
48
|
+
<img src={slide.imageUrl} alt={slide.title} className="w-full h-full object-cover group-hover:opacity-30 transition-all duration-300 ease-in-out rounded-2xl" />
|
|
49
|
+
<img src={slide.pngUrl} alt={slide.title} className="absolute inset-0 w-full h-full object-cover group-hover:translate-z-15 transition-all duration-300 ease-in-out rounded-2xl" />
|
|
50
|
+
</div>
|
|
51
|
+
<div className="relative flex flex-col justify-end h-full text-white p-5 sm:p-8" style={{ transformStyle: 'preserve-3d', perspective: '1500px' }}>
|
|
52
|
+
<h2 className="text-2xl sm:text-3xl font-bold group-hover:translate-z-18 transition-all duration-300 ease-in-out">{slide.title}</h2>
|
|
53
|
+
<p className="text-lime-400 mt-1 group-hover:translate-z-18 transition-all duration-300 ease-in-out">{slide.subtitle}</p>
|
|
54
|
+
<p className="text-slate-300 mt-3 sm:mt-4 text-xs sm:text-sm leading-relaxed line-clamp-3 sm:line-clamp-none group-hover:translate-z-18 transition-all duration-300 ease-in-out">{slide.text}</p>
|
|
55
|
+
</div>
|
|
56
|
+
</motion.div>
|
|
57
|
+
);
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
// --- Custom Parallax Carousel Component ---
|
|
62
|
+
const ParallaxCarousel = ({ slides }) => {
|
|
63
|
+
const [activeIndex, setActiveIndex] = useState(0);
|
|
64
|
+
const animatingRef = useRef(false);
|
|
65
|
+
const animTimeoutRef = useRef(null);
|
|
66
|
+
|
|
67
|
+
const [isScrolling, setIsScrolling] = useState(false);
|
|
68
|
+
const scrollTimeoutRef = useRef(null);
|
|
69
|
+
|
|
70
|
+
// ✅ REMOVED: isHovered state hata diya gaya hai.
|
|
71
|
+
|
|
72
|
+
const NAV_ANIM_MS = 600;
|
|
73
|
+
const handleNext = () => {
|
|
74
|
+
if (animatingRef.current) return;
|
|
75
|
+
animatingRef.current = true;
|
|
76
|
+
clearTimeout(animTimeoutRef.current);
|
|
77
|
+
setActiveIndex((prevIndex) => (prevIndex + 1) % slides.length);
|
|
78
|
+
animTimeoutRef.current = setTimeout(() => {
|
|
79
|
+
animatingRef.current = false;
|
|
80
|
+
}, NAV_ANIM_MS);
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const handlePrev = () => {
|
|
84
|
+
if (animatingRef.current) return;
|
|
85
|
+
animatingRef.current = true;
|
|
86
|
+
clearTimeout(animTimeoutRef.current);
|
|
87
|
+
setActiveIndex((prevIndex) => (prevIndex - 1 + slides.length) % slides.length);
|
|
88
|
+
animTimeoutRef.current = setTimeout(() => {
|
|
89
|
+
animatingRef.current = false;
|
|
90
|
+
}, NAV_ANIM_MS);
|
|
91
|
+
};
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
const handleScroll = () => {
|
|
94
|
+
setIsScrolling(true);
|
|
95
|
+
clearTimeout(scrollTimeoutRef.current);
|
|
96
|
+
scrollTimeoutRef.current = setTimeout(() => {
|
|
97
|
+
setIsScrolling(false);
|
|
98
|
+
}, 150);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
window.addEventListener('scroll', handleScroll, { passive: true });
|
|
102
|
+
|
|
103
|
+
return () => {
|
|
104
|
+
window.removeEventListener('scroll', handleScroll);
|
|
105
|
+
clearTimeout(scrollTimeoutRef.current);
|
|
106
|
+
};
|
|
107
|
+
}, []);
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
return (
|
|
111
|
+
<div className="relative w-full flex flex-col items-center px-2">
|
|
112
|
+
<div
|
|
113
|
+
className="relative w-full h-[380px] sm:h-[480px]"
|
|
114
|
+
style={{ perspective: '1200px' }}
|
|
115
|
+
>
|
|
116
|
+
{slides.map((slide, index) => {
|
|
117
|
+
let positionOffset = index - activeIndex;
|
|
118
|
+
if (positionOffset > slides.length / 2) {
|
|
119
|
+
positionOffset -= slides.length;
|
|
120
|
+
} else if (positionOffset < -slides.length / 2) {
|
|
121
|
+
positionOffset += slides.length;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const isVisible = Math.abs(positionOffset) <= 2;
|
|
125
|
+
|
|
126
|
+
return (
|
|
127
|
+
// Separate x / z / scale (not one transform string) and no preserve-3d
|
|
128
|
+
// here: opacity below 1 flattens a 3D layer, which made cards pop
|
|
129
|
+
// while they moved. The stacking order is set at once, not animated.
|
|
130
|
+
<motion.div
|
|
131
|
+
key={index}
|
|
132
|
+
className={`absolute left-1/2 top-1/2 aspect-[25/26] w-[min(80%,25rem)] sm:w-[min(88%,25rem)] ${positionOffset === 0 ? '' : 'pointer-events-none'}`}
|
|
133
|
+
initial={false}
|
|
134
|
+
style={{ y: '-50%', zIndex: slides.length - Math.abs(positionOffset), willChange: 'transform, opacity' }}
|
|
135
|
+
animate={{
|
|
136
|
+
x: `${-50 + positionOffset * 50}%`,
|
|
137
|
+
z: -Math.abs(positionOffset) * 250,
|
|
138
|
+
scale: 1 - Math.abs(positionOffset) * 0.15,
|
|
139
|
+
opacity: isVisible ? 1 - Math.abs(positionOffset) * 0.45 : 0,
|
|
140
|
+
}}
|
|
141
|
+
transition={{ type: 'spring', stiffness: 160, damping: 22 }}
|
|
142
|
+
>
|
|
143
|
+
<CarouselCard slide={slide} isCenter={positionOffset === 0} isScrolling={isScrolling} />
|
|
144
|
+
</motion.div>
|
|
145
|
+
);
|
|
146
|
+
})}
|
|
147
|
+
</div>
|
|
148
|
+
|
|
149
|
+
<motion.button onClick={handlePrev} className="absolute left-0 top-1/2 -translate-y-1/2 z-30 p-1.5 sm:p-3 rounded-full text-slate-400 hover:text-lime-400 transition-colors" whileTap={{ scale: 0.9 }}>
|
|
150
|
+
<ArrowLeft size={32} className="h-5 w-5 sm:h-8 sm:w-8" />
|
|
151
|
+
</motion.button>
|
|
152
|
+
<motion.button onClick={handleNext} className="absolute right-0 top-1/2 -translate-y-1/2 z-30 p-1.5 sm:p-3 rounded-full text-slate-400 hover:text-lime-400 transition-colors" whileTap={{ scale: 0.9 }}>
|
|
153
|
+
<ArrowRight size={32} className="h-5 w-5 sm:h-8 sm:w-8" />
|
|
154
|
+
</motion.button>
|
|
155
|
+
</div>
|
|
156
|
+
);
|
|
157
|
+
};
|
|
158
|
+
|
|
154
159
|
export default ParallaxCarousel;
|
|
@@ -87,13 +87,18 @@ export function useTheme(themes = defaultThemes, { storageKey = "theme", default
|
|
|
87
87
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
88
88
|
}, [themes, store, id]);
|
|
89
89
|
|
|
90
|
-
|
|
90
|
+
// onCommit runs in the same update as the theme change, so anything it
|
|
91
|
+
// changes (like closing a menu) lands in the same view-transition frame.
|
|
92
|
+
const setTheme = (next, origin, onCommit) => {
|
|
91
93
|
const theme = themes.find((x) => x.id === next);
|
|
92
|
-
if (!theme || next === store.get()) return;
|
|
94
|
+
if (!theme || next === store.get()) return onCommit?.();
|
|
93
95
|
write(storageKey, next);
|
|
94
96
|
const commit = () => {
|
|
95
97
|
applyTheme(theme, rootEl());
|
|
96
|
-
flushSync(() =>
|
|
98
|
+
flushSync(() => {
|
|
99
|
+
store.set(next);
|
|
100
|
+
onCommit?.();
|
|
101
|
+
});
|
|
97
102
|
};
|
|
98
103
|
const reduced = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
|
|
99
104
|
if (!document.startViewTransition || reduced) {
|
|
@@ -130,6 +135,9 @@ const Palette = () => (
|
|
|
130
135
|
const ThemeSwitcher = ({ themes = defaultThemes, placement = "down", align = "right", label, storageKey = "theme", target, className = "", buttonClassName = "" }) => {
|
|
131
136
|
const [id, setTheme] = useTheme(themes, { storageKey, target });
|
|
132
137
|
const [open, setOpen] = useState(false);
|
|
138
|
+
// Closed by picking a theme: hide at once (an exit animation would be caught
|
|
139
|
+
// half-way by the theme transition snapshot and flicker).
|
|
140
|
+
const [picked, setPicked] = useState(false);
|
|
133
141
|
const root = useRef(null);
|
|
134
142
|
const current = themes.find((x) => x.id === id) ?? themes[0];
|
|
135
143
|
const up = placement === "up";
|
|
@@ -153,21 +161,27 @@ const ThemeSwitcher = ({ themes = defaultThemes, placement = "down", align = "ri
|
|
|
153
161
|
aria-label="Choose colour theme"
|
|
154
162
|
aria-haspopup="true"
|
|
155
163
|
aria-expanded={open}
|
|
156
|
-
onClick={() =>
|
|
164
|
+
onClick={() => {
|
|
165
|
+
setPicked(false);
|
|
166
|
+
setOpen((o) => !o);
|
|
167
|
+
}}
|
|
157
168
|
className={`relative inline-flex items-center justify-center gap-2 border border-[var(--line)] bg-[var(--surface)] text-[var(--ink)] transition-colors hover:border-[var(--accent)] ${label ? "h-9 rounded-full px-3.5 text-sm font-medium" : "h-9 w-9 rounded-full"} ${buttonClassName}`}
|
|
158
169
|
>
|
|
159
170
|
<Palette />
|
|
160
171
|
{label && <span>{label}</span>}
|
|
161
172
|
<span aria-hidden="true" className="absolute right-0.5 top-0.5 h-2 w-2 rounded-full ring-2 ring-[var(--surface)]" style={{ background: current?.vars["--accent"] }} />
|
|
162
173
|
</button>
|
|
163
|
-
|
|
174
|
+
{/* custom passes the latest "picked" to the panel that is leaving. */}
|
|
175
|
+
<AnimatePresence custom={picked}>
|
|
164
176
|
{open && (
|
|
165
177
|
<motion.div
|
|
166
178
|
role="dialog"
|
|
167
179
|
aria-label="Colour themes"
|
|
168
180
|
initial={{ opacity: 0, y: up ? 8 : -8, scale: 0.96 }}
|
|
169
181
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
|
170
|
-
|
|
182
|
+
custom={picked}
|
|
183
|
+
variants={{ leave: (p) => (p ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, y: up ? 8 : -8, scale: 0.96 }) }}
|
|
184
|
+
exit="leave"
|
|
171
185
|
transition={{ type: "spring", stiffness: 380, damping: 28 }}
|
|
172
186
|
className={`absolute z-50 w-[17.5rem] max-w-[calc(100vw-2rem)] rounded-2xl border border-[var(--line)] bg-[var(--surface)] p-3 text-[var(--ink)] shadow-[0_24px_60px_-16px_rgba(0,0,0,0.35)] ${up ? "bottom-full mb-2" : "top-full mt-2"} ${align === "left" ? "left-0" : "right-0"}`}
|
|
173
187
|
style={{ transformOrigin: `${up ? "bottom" : "top"} ${align === "left" ? "left" : "right"}` }}
|
|
@@ -185,8 +199,10 @@ const ThemeSwitcher = ({ themes = defaultThemes, placement = "down", align = "ri
|
|
|
185
199
|
animate={{ y: 0 }}
|
|
186
200
|
transition={{ delay: 0.03 * i, type: "spring", stiffness: 380, damping: 28 }}
|
|
187
201
|
onClick={(e) => {
|
|
188
|
-
setTheme(theme.id, e.currentTarget.querySelector("[data-swatch]"))
|
|
189
|
-
|
|
202
|
+
setTheme(theme.id, e.currentTarget.querySelector("[data-swatch]"), () => {
|
|
203
|
+
setPicked(true);
|
|
204
|
+
setOpen(false);
|
|
205
|
+
});
|
|
190
206
|
}}
|
|
191
207
|
className={`relative flex items-center gap-2.5 rounded-xl py-2 pl-2 pr-5 text-left text-[0.82rem] font-medium transition-colors hover:bg-[var(--bg)] ${active ? "bg-[var(--bg)]" : ""}`}
|
|
192
208
|
style={{ animation: `apex-theme-in 240ms ease-out ${30 * i}ms backwards` }}
|