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 CHANGED
@@ -4,7 +4,7 @@
4
4
  [![npm downloads](https://img.shields.io/npm/dm/apex-ui-kit?color=a3e635)](https://www.npmjs.com/package/apex-ui-kit)
5
5
  [![license](https://img.shields.io/npm/l/apex-ui-kit?color=a3e635)](./Licence.md)
6
6
 
7
- **117 free, animated UI components for React.** Pick one, add it with a single command, and the source lands in your project so you can read it and change anything.
7
+ **116 free, animated UI components for React.** Pick one, add it with a single command, and the source lands in your project so you can read it and change anything.
8
8
 
9
9
  Built with React, Tailwind CSS and Framer Motion, plus GSAP, Canvas and WebGL where a component needs them.
10
10
 
@@ -15,7 +15,7 @@ Built with React, Tailwind CSS and Framer Motion, plus GSAP, Canvas and WebGL wh
15
15
 
16
16
  ## Why ApexUI
17
17
 
18
- - **117 components** across backgrounds, cards, text effects, navigation, buttons, interactive UI, AI interfaces, loaders and maps.
18
+ - **116 components** across backgrounds, cards, text effects, navigation, buttons, interactive UI, AI interfaces, loaders and maps.
19
19
  - **You own the code.** The CLI copies plain `.jsx` files into your project, no runtime package to keep in sync.
20
20
  - **Every docs page has** a live preview, a playground to tweak props, a Desktop / Tablet / Mobile preview, the full props table and a ready prompt you can paste into ChatGPT or Claude.
21
21
  - **Smooth on real devices.** Animations stick to transform and opacity, respect reduced motion, and work with mouse, touch and keyboard.
@@ -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.10
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, Card Swap 3D, 3D Text Ring, Elastic Slider, Stepper and Pixel Transition.
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-0 h-[2px] w-full rounded-full bg-current" animate={open ? { top: "50%", rotate: 45, y: "-50%" } : { top: "0%", rotate: 0, y: 0 }} />
25
- <motion.span className="absolute left-0 top-1/2 h-[2px] w-full -translate-y-1/2 rounded-full bg-current" animate={{ opacity: open ? 0 : 1, scaleX: open ? 0.3 : 1 }} />
26
- <motion.span className="absolute bottom-0 left-0 h-[2px] w-full rounded-full bg-current" animate={open ? { bottom: "50%", rotate: -45, y: "50%" } : { bottom: "0%", rotate: 0, y: 0 }} />
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 initial={{ height: 0, opacity: 0 }} animate={{ height: "auto", opacity: 1 }} exit={{ height: 0, opacity: 0 }} transition={{ duration: 0.3, ease: [0.22, 1, 0.36, 1] }} className="overflow-hidden @xl:hidden">
137
- <ul className="space-y-1 px-1 pb-2 pt-3">
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: -14, opacity: 0 }} animate={{ x: 0, opacity: 1 }} transition={{ delay: 0.04 * i + 0.05 }}>
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.04 * links.length + 0.08 }} className="pt-2">
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
- const a = el.getBoundingClientRect();
153
- const p = b.getBoundingClientRect();
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 = r.width + PAD * 2;
183
- const h = r.height + PAD * 2;
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
- const rect = el.getBoundingClientRect();
40
- const parentRect = el.parentElement.getBoundingClientRect();
41
- const left = rect.left - parentRect.left;
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 rect = el.getBoundingClientRect();
63
- const parentRect = el.parentElement.getBoundingClientRect();
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
- /* eslint-disable no-unused-vars */
2
- import React, { useState, useEffect, useRef } from 'react';
3
- import { motion, useMotionValue, useTransform } from 'framer-motion';
4
- import { ArrowLeft, ArrowRight } from 'lucide-react';
5
- const CarouselCard = ({ slide, isCenter, isScrolling }) => {
6
- const cardRef = useRef(null);
7
- const x = useMotionValue(0);
8
- const y = useMotionValue(0);
9
-
10
- const rotateX = useTransform(y, [-200, 200], [15, -15]);
11
- const rotateY = useTransform(x, [-200, 200], [-15, 15]);
12
-
13
- const handleMouseMove = (event) => {
14
- if (!cardRef.current || !isCenter || isScrolling) return;
15
- const rect = cardRef.current.getBoundingClientRect();
16
- x.set(event.clientX - rect.left - rect.width / 2);
17
- y.set(event.clientY - rect.top - rect.height / 2);
18
- };
19
-
20
- const handleMouseLeave = () => {
21
- x.set(0);
22
- y.set(0);
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
- rotateX: isCenter ? rotateX : 0,
40
- rotateY: isCenter ? rotateY : 0,
41
- }}
42
- onMouseMove={handleMouseMove}
43
- onMouseLeave={handleMouseLeave}
44
- >
45
- {/* Slide Content */}
46
- <div className="absolute inset-0 w-full h-full" style={{ transformStyle: 'preserve-3d', perspective: '1500px' }}>
47
- <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" />
48
- <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" />
49
- </div>
50
- <div className="relative flex flex-col justify-end h-full text-white p-5 sm:p-8" style={{ transformStyle: 'preserve-3d', perspective: '1500px' }}>
51
- <h2 className="text-2xl sm:text-3xl font-bold group-hover:translate-z-18 transition-all duration-300 ease-in-out">{slide.title}</h2>
52
- <p className="text-lime-400 mt-1 group-hover:translate-z-18 transition-all duration-300 ease-in-out">{slide.subtitle}</p>
53
- <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>
54
- </div>
55
- </motion.div>
56
- );
57
- };
58
-
59
-
60
- // --- Custom Parallax Carousel Component ---
61
- const ParallaxCarousel = ({ slides }) => {
62
- const [activeIndex, setActiveIndex] = useState(0);
63
- const animatingRef = useRef(false);
64
- const animTimeoutRef = useRef(null);
65
-
66
- const [isScrolling, setIsScrolling] = useState(false);
67
- const scrollTimeoutRef = useRef(null);
68
-
69
- // ✅ REMOVED: isHovered state hata diya gaya hai.
70
-
71
- const NAV_ANIM_MS = 600;
72
- const handleNext = () => {
73
- if (animatingRef.current) return;
74
- animatingRef.current = true;
75
- clearTimeout(animTimeoutRef.current);
76
- setActiveIndex((prevIndex) => (prevIndex + 1) % slides.length);
77
- animTimeoutRef.current = setTimeout(() => {
78
- animatingRef.current = false;
79
- }, NAV_ANIM_MS);
80
- };
81
-
82
- const handlePrev = () => {
83
- if (animatingRef.current) return;
84
- animatingRef.current = true;
85
- clearTimeout(animTimeoutRef.current);
86
- setActiveIndex((prevIndex) => (prevIndex - 1 + slides.length) % slides.length);
87
- animTimeoutRef.current = setTimeout(() => {
88
- animatingRef.current = false;
89
- }, NAV_ANIM_MS);
90
- };
91
- useEffect(() => {
92
- const handleScroll = () => {
93
- setIsScrolling(true);
94
- clearTimeout(scrollTimeoutRef.current);
95
- scrollTimeoutRef.current = setTimeout(() => {
96
- setIsScrolling(false);
97
- }, 150);
98
- };
99
-
100
- window.addEventListener('scroll', handleScroll, { passive: true });
101
-
102
- return () => {
103
- window.removeEventListener('scroll', handleScroll);
104
- clearTimeout(scrollTimeoutRef.current);
105
- };
106
- }, []);
107
-
108
-
109
- return (
110
- <div className="relative w-full flex flex-col items-center px-2">
111
- <div
112
- className="relative w-full h-[380px] sm:h-[480px]"
113
- style={{ perspective: '1200px', transformStyle: 'preserve-3d' }}
114
- >
115
- {slides.map((slide, index) => {
116
- let positionOffset = index - activeIndex;
117
- if (positionOffset > slides.length / 2) {
118
- positionOffset -= slides.length;
119
- } else if (positionOffset < -slides.length / 2) {
120
- positionOffset += slides.length;
121
- }
122
-
123
- const isVisible = Math.abs(positionOffset) <= 2;
124
-
125
- return (
126
- <motion.div
127
- key={index}
128
- className={`absolute left-1/2 top-1/2 aspect-[25/26] w-[min(80%,25rem)] sm:w-[min(88%,25rem)] -translate-x-1/2 -translate-y-1/2 ${positionOffset === 0 ? '' : 'pointer-events-none'}`}
129
- initial={false}
130
- style={{ transformStyle: 'preserve-3d', willChange: 'transform, opacity' }}
131
- animate={{
132
- transform: `translateX(${positionOffset * 50}%) translateZ(${-Math.abs(positionOffset) * 250}px) scale(${1 - Math.abs(positionOffset) * 0.15})`,
133
- opacity: isVisible ? (1 - Math.abs(positionOffset) * 0.45) : 0,
134
- zIndex: slides.length - Math.abs(positionOffset),
135
- }}
136
- transition={{ type: 'spring', stiffness: 160, damping: 22 }}
137
- >
138
- <CarouselCard slide={slide} isCenter={positionOffset === 0} isScrolling={isScrolling} />
139
- </motion.div>
140
- );
141
- })}
142
- </div>
143
-
144
- <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 }}>
145
- <ArrowLeft size={32} className="h-5 w-5 sm:h-8 sm:w-8" />
146
- </motion.button>
147
- <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 }}>
148
- <ArrowRight size={32} className="h-5 w-5 sm:h-8 sm:w-8" />
149
- </motion.button>
150
- </div>
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
- const setTheme = (next, origin) => {
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(() => store.set(next));
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={() => setOpen((o) => !o)}
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
- <AnimatePresence>
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
- exit={{ opacity: 0, y: up ? 8 : -8, scale: 0.96 }}
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
- setOpen(false);
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` }}