apex-ui-kit 2.12.1 → 2.12.2

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.
@@ -127,7 +127,6 @@ Every component's props, variants and copy-ready code are on its docs page.
127
127
  | --- | --- |
128
128
  | [Bounce Cards](https://apex-ui.in/components/bounce-cards) | `bounce-cards` |
129
129
  | [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
130
  | [Cards](https://apex-ui.in/components/cards) | `basic-card`, `meteor-card` |
132
131
  | [Distortion Image](https://apex-ui.in/components/distortion-image) | `distortion-image` |
133
132
  | [Folder](https://apex-ui.in/components/folder) | `folder` |
@@ -261,13 +260,14 @@ Every component's props, variants and copy-ready code are on its docs page.
261
260
  | [Dot Globe](https://apex-ui.in/components/dot-globe) | `dot-globe` |
262
261
  | [World Map](https://apex-ui.in/components/world-map) | `world-map` |
263
262
 
264
- ## What's new in 2.10
263
+ ## What's new in 2.12
265
264
 
265
+ - 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
266
  - 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
267
  - 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
268
  - 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
269
  - 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.
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, 3D Text Ring, Elastic Slider, Stepper and Pixel Transition.
271
271
  - 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
272
  - AI Model Prompt now exports `DEFAULT_LLM_MODELS` and an index file, so you can import both from the folder.
273
273
  - 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` }}
package/dist/cli/index.js CHANGED
@@ -111,7 +111,6 @@ const componentMap = {
111
111
  "pixel-transition": "PixelTransition",
112
112
  "folder": "Folder",
113
113
  "bounce-cards": "BounceCards",
114
- "card-swap-3d": "CardSwap3D",
115
114
  "gooey-nav": "GooeyNav",
116
115
  "liquid-blob-3d": "LiquidBlob3D",
117
116
  "particle-morph": "ParticleMorph",
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "apex-ui-kit",
3
- "version": "2.12.1",
3
+ "version": "2.12.2",
4
4
  "license": "MIT",
5
- "description": "117 free, animated UI components for React, styled with Tailwind CSS. Add any of them with one CLI command.",
5
+ "description": "116 free, animated UI components for React, styled with Tailwind CSS. Add any of them with one CLI command.",
6
6
  "homepage": "https://apex-ui.in",
7
7
  "repository": {
8
8
  "type": "git",
@@ -1,104 +0,0 @@
1
- import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
2
- import { motion } from "framer-motion";
3
- import { Code2, Layers, Sparkles, Zap } from "lucide-react";
4
-
5
- /*
6
- * Card Swap 3D: cards stacked in real 3D, turned toward the viewer and
7
- * receding into the distance. On a timer the front card drops away and
8
- * returns to the back while the rest step forward. Pauses while hovered;
9
- * click to swap now. The whole stack scales down to fit narrow containers.
10
- */
11
-
12
- const DEFAULT_CARDS = [
13
- { icon: Sparkles, title: "Smooth", body: "Springs tuned to feel natural, never floaty." },
14
- { icon: Layers, title: "Composable", body: "Drop components in and style them with Tailwind." },
15
- { icon: Code2, title: "Yours", body: "Copy the source or add it with one CLI command." },
16
- { icon: Zap, title: "Fast", body: "Transforms and opacity only, 60fps on phones." },
17
- ];
18
-
19
- const CardSwap3D = ({ cards = DEFAULT_CARDS, delay = 3200, distanceX = 3.6, distanceY = 3.2, depth = 70, tilt = 18, skew = 0, pauseOnHover = true, accentColor = "#a3e635", theme = "dark", width = 18, className = "" }) => {
20
- // order[0] is the card in front.
21
- const [order, setOrder] = useState(() => cards.map((_, i) => i));
22
- const [dropping, setDropping] = useState(null);
23
- const [paused, setPaused] = useState(false);
24
- const [fit, setFit] = useState(1);
25
- const outer = useRef(null);
26
- const dark = theme !== "light";
27
- const n = cards.length;
28
- // Footprint of the whole stack in rem (card plus the steps behind it).
29
- // (+5: the 3D turn makes the cards project a little wider than they are.)
30
- const footW = width + distanceX * (n - 1) + 5;
31
- const footH = width * 0.68 + distanceY * (n - 1) + 2;
32
-
33
- const swap = () => {
34
- if (dropping !== null) return;
35
- setDropping(order[0]);
36
- setTimeout(() => {
37
- setOrder((o) => [...o.slice(1), o[0]]);
38
- setDropping(null);
39
- }, 520);
40
- };
41
-
42
- useEffect(() => {
43
- if (paused) return undefined;
44
- const id = setInterval(swap, delay);
45
- return () => clearInterval(id);
46
- });
47
-
48
- // Scale the stack down when its container is narrower than its footprint.
49
- useLayoutEffect(() => {
50
- const el = outer.current?.parentElement;
51
- if (!el) return undefined;
52
- const ro = new ResizeObserver(() => {
53
- const rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
54
- setFit(Math.min(1, el.clientWidth / (footW * rem)));
55
- });
56
- ro.observe(el);
57
- return () => ro.disconnect();
58
- }, [footW]);
59
-
60
- return (
61
- <div ref={outer} className={`relative mx-auto ${className}`} style={{ width: `${footW * fit}rem`, height: `${footH * fit}rem` }}>
62
- <div
63
- className="absolute left-0 top-0 origin-top-left [perspective:1200px]"
64
- style={{ width: `${footW}rem`, height: `${footH}rem`, transform: `scale(${fit})` }}
65
- onPointerEnter={() => pauseOnHover && setPaused(true)}
66
- onPointerLeave={() => setPaused(false)}
67
- onClick={swap}
68
- >
69
- <div className="absolute [transform-style:preserve-3d]" style={{ left: "2.5rem", bottom: "1rem", width: `${width}rem`, height: `${width * 0.68}rem`, transform: `rotateY(${-tilt}deg) rotateX(${tilt * 0.35}deg)` }}>
70
- {cards.map((card, i) => {
71
- const slot = order.indexOf(i);
72
- const isDropping = dropping === i;
73
- const Icon = card.icon ?? Sparkles;
74
- return (
75
- <motion.div
76
- key={i}
77
- className={`absolute inset-0 cursor-pointer overflow-hidden rounded-2xl border p-6 ${dark ? "border-white/10 bg-neutral-950 text-white" : "border-black/10 bg-white text-neutral-900"}`}
78
- style={{ zIndex: isDropping ? 60 : n - slot, boxShadow: "0 30px 60px -28px rgba(0,0,0,0.65)" }}
79
- initial={false}
80
- animate={
81
- isDropping
82
- ? { x: "-2rem", y: "16rem", z: 120, rotateZ: -8, skewY: skew, opacity: 0 }
83
- : { x: `${slot * distanceX}rem`, y: `${-slot * distanceY}rem`, z: -slot * depth, rotateZ: 0, skewY: skew, opacity: slot > 4 ? 0 : 1 - slot * 0.12 }
84
- }
85
- transition={isDropping ? { duration: 0.5, ease: [0.55, 0, 0.8, 0.2] } : { type: "spring", stiffness: 150, damping: 21 }}
86
- >
87
- <div className="flex items-center gap-2.5">
88
- <span className="grid h-9 w-9 place-items-center rounded-xl text-black" style={{ background: accentColor }}>
89
- <Icon className="h-[18px] w-[18px]" />
90
- </span>
91
- <p className="text-lg font-semibold">{card.title}</p>
92
- </div>
93
- <p className={`mt-3 text-[0.92rem] leading-relaxed ${dark ? "text-white/60" : "text-neutral-500"}`}>{card.body}</p>
94
- <div className="pointer-events-none absolute -bottom-10 -right-10 h-32 w-32 rounded-full opacity-20 blur-2xl" style={{ background: accentColor }} />
95
- </motion.div>
96
- );
97
- })}
98
- </div>
99
- </div>
100
- </div>
101
- );
102
- };
103
-
104
- export default CardSwap3D;