apex-ui-kit 2.1.2 → 2.2.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.
Files changed (24) hide show
  1. package/dist/ApexUI-Kit/templates/AnimatedButton/AnimatedButton.jsx +251 -0
  2. package/dist/ApexUI-Kit/templates/AnimatedTabs/AnimatedTabs.jsx +99 -0
  3. package/dist/ApexUI-Kit/templates/BlurRevealText/BlurRevealText.jsx +60 -0
  4. package/dist/ApexUI-Kit/templates/Carousel3D/Carousel3D.jsx +222 -0
  5. package/dist/ApexUI-Kit/templates/DotGlobe/DotGlobe.jsx +289 -0
  6. package/dist/ApexUI-Kit/templates/DotWaveBackground/DotWaveBackground.jsx +189 -0
  7. package/dist/ApexUI-Kit/templates/DynamicIsland/DynamicIsland.jsx +221 -0
  8. package/dist/ApexUI-Kit/templates/FaqAccordion/FaqAccordion.jsx +88 -0
  9. package/dist/ApexUI-Kit/templates/FlipClock/FlipClock.jsx +121 -0
  10. package/dist/ApexUI-Kit/templates/LiquidGradient/LiquidGradient.jsx +238 -0
  11. package/dist/ApexUI-Kit/templates/LoadingScreen/LoadingScreen.jsx +361 -0
  12. package/dist/ApexUI-Kit/templates/MagneticCursor/MagneticCursor.jsx +306 -0
  13. package/dist/ApexUI-Kit/templates/MagnifyDock/MagnifyDock.jsx +154 -0
  14. package/dist/ApexUI-Kit/templates/ProfileCard/ProfileCard.jsx +213 -0
  15. package/dist/ApexUI-Kit/templates/RollingCounter/RollingCounter.jsx +143 -0
  16. package/dist/ApexUI-Kit/templates/ScrambleText/ScrambleText.jsx +132 -0
  17. package/dist/ApexUI-Kit/templates/SidebarNav/SidebarNav.jsx +220 -0
  18. package/dist/ApexUI-Kit/templates/SpotlightGrid/SpotlightGrid.jsx +110 -0
  19. package/dist/ApexUI-Kit/templates/ToastStack/ToastStack.jsx +108 -0
  20. package/dist/ApexUI-Kit/templates/ToolTip/ToolTip.jsx +5 -3
  21. package/dist/ApexUI-Kit/templates/WebGLCarousel/WebGLCarousel.jsx +326 -0
  22. package/dist/ApexUI-Kit/templates/WorldMap/WorldMap.jsx +359 -0
  23. package/dist/cli/index.js +25 -0
  24. package/package.json +1 -1
@@ -0,0 +1,251 @@
1
+ import React, { useRef, useState } from "react";
2
+ import { AnimatePresence, motion } from "framer-motion";
3
+ import { ArrowRight, Check, Loader2 } from "lucide-react";
4
+
5
+ /*
6
+ * Animated Button: one button, seven feels. Pick a variant:
7
+ * shine – a gleam sweeps across on hover
8
+ * fill – color floods in from where the pointer entered and drains out where it left
9
+ * border – a light beam runs around the border
10
+ * press – a chunky 3D key that sinks when pressed
11
+ * loading – spins while onClick's promise runs, then shows a check
12
+ * ripple – a ripple spreads from every click
13
+ * arrow – the arrow slides through and the label nudges over
14
+ * Every motion is a GPU transform with one shared easing, so it stays smooth.
15
+ */
16
+
17
+ const EASE = "cubic-bezier(0.22, 1, 0.36, 1)";
18
+
19
+ const SIZES = {
20
+ sm: { box: "h-9 px-4 text-[0.8rem] gap-1.5", pad: "px-4 gap-1.5" },
21
+ md: { box: "h-11 px-5 text-[0.9rem] gap-2", pad: "px-5 gap-2" },
22
+ lg: { box: "h-[3.25rem] px-7 text-base gap-2.5", pad: "px-7 gap-2.5" },
23
+ };
24
+
25
+ // Black or white text, whichever reads better on the accent.
26
+ const inkOn = (hex) => {
27
+ let h = String(hex).replace("#", "");
28
+ if (h.length === 3) h = h.split("").map((c) => c + c).join("");
29
+ const n = parseInt(h, 16) || 0;
30
+ const l = (0.299 * ((n >> 16) & 255) + 0.587 * ((n >> 8) & 255) + 0.114 * (n & 255)) / 255;
31
+ return l > 0.6 ? "#0a0a0a" : "#ffffff";
32
+ };
33
+
34
+ const AnimatedButton = ({ variant = "shine", children = "Get started", color = "#a3e635", size = "md", onClick, disabled, className = "", ...rest }) => {
35
+ const ref = useRef(null);
36
+ const [fill, setFill] = useState({ x: 0, y: 0, d: 0, on: false });
37
+ const [ripples, setRipples] = useState([]);
38
+ const [status, setStatus] = useState("idle");
39
+ const ink = inkOn(color);
40
+ const s = SIZES[size] ?? SIZES.md;
41
+ const base = `relative inline-flex select-none items-center justify-center overflow-hidden rounded-full font-semibold outline-none [-webkit-tap-highlight-color:transparent] focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 ${s.box}`;
42
+ const smooth = { transitionTimingFunction: EASE };
43
+
44
+ const localPoint = (e) => {
45
+ const r = ref.current.getBoundingClientRect();
46
+ return { x: e.clientX - r.left, y: e.clientY - r.top, w: r.width, h: r.height };
47
+ };
48
+
49
+ if (variant === "shine") {
50
+ return (
51
+ <button
52
+ ref={ref}
53
+ type="button"
54
+ onClick={onClick}
55
+ disabled={disabled}
56
+ className={`group ${base} bg-neutral-950 text-white transition-[transform,box-shadow] duration-300 hover:-translate-y-0.5 hover:shadow-[0_12px_32px_-12px_var(--c)] active:translate-y-0 active:scale-[0.97] active:duration-100 ${className}`}
57
+ style={{ "--c": color, ...smooth }}
58
+ {...rest}
59
+ >
60
+ <span aria-hidden="true" className="absolute inset-0 rounded-full transition-opacity duration-300 group-hover:opacity-100" style={{ boxShadow: `inset 0 0 0 1px ${color}66, inset 0 1px 0 rgba(255,255,255,0.12)` }} />
61
+ <span
62
+ aria-hidden="true"
63
+ className="absolute inset-y-0 left-0 w-1/2 -translate-x-[120%] skew-x-[-20deg] bg-gradient-to-r from-transparent via-white/35 to-transparent transition-transform duration-[900ms] will-change-transform group-hover:translate-x-[260%]"
64
+ style={smooth}
65
+ />
66
+ <span className="relative">{children}</span>
67
+ </button>
68
+ );
69
+ }
70
+
71
+ if (variant === "fill") {
72
+ // One circle big enough to cover the button, scaled 0 → 1 from the entry point.
73
+ const track = (e, on) => {
74
+ const p = localPoint(e);
75
+ setFill({ x: p.x, y: p.y, d: Math.hypot(Math.max(p.x, p.w - p.x), Math.max(p.y, p.h - p.y)) * 2, on });
76
+ };
77
+ return (
78
+ <button
79
+ ref={ref}
80
+ type="button"
81
+ onClick={onClick}
82
+ disabled={disabled}
83
+ onPointerEnter={(e) => track(e, true)}
84
+ onPointerLeave={(e) => track(e, false)}
85
+ className={`${base} border-[1.5px] transition-[color,transform] duration-500 active:scale-[0.97] active:duration-100 ${className}`}
86
+ style={{ borderColor: color, color: fill.on ? ink : color, ...smooth }}
87
+ {...rest}
88
+ >
89
+ <span
90
+ aria-hidden="true"
91
+ className="pointer-events-none absolute rounded-full will-change-transform"
92
+ style={{
93
+ left: fill.x - fill.d / 2,
94
+ top: fill.y - fill.d / 2,
95
+ width: fill.d,
96
+ height: fill.d,
97
+ background: color,
98
+ transform: `scale(${fill.on ? 1 : 0})`,
99
+ transition: `transform 550ms ${EASE}`,
100
+ }}
101
+ />
102
+ <span className="relative">{children}</span>
103
+ </button>
104
+ );
105
+ }
106
+
107
+ if (variant === "border") {
108
+ return (
109
+ <button
110
+ ref={ref}
111
+ type="button"
112
+ onClick={onClick}
113
+ disabled={disabled}
114
+ className={`group ${base} text-white transition-[transform,box-shadow] duration-300 hover:shadow-[0_0_30px_-8px_var(--c)] active:scale-[0.97] active:duration-100 ${className}`}
115
+ style={{ "--c": color, padding: "1.5px", ...smooth }}
116
+ {...rest}
117
+ >
118
+ <motion.span
119
+ aria-hidden="true"
120
+ className="absolute left-1/2 top-1/2 aspect-square w-[260%] -translate-x-1/2 -translate-y-1/2 will-change-transform"
121
+ style={{ background: `conic-gradient(from 0deg, transparent 0 68%, ${color}66 78%, ${color} 88%, transparent 100%)` }}
122
+ animate={{ rotate: 360 }}
123
+ transition={{ duration: 2.8, repeat: Infinity, ease: "linear" }}
124
+ />
125
+ <span className={`relative flex h-full w-full items-center justify-center rounded-full bg-neutral-950 transition-colors duration-300 group-hover:bg-neutral-900 ${s.pad}`}>{children}</span>
126
+ </button>
127
+ );
128
+ }
129
+
130
+ if (variant === "press") {
131
+ const edge = `color-mix(in srgb, ${color} 55%, #000)`;
132
+ return (
133
+ <button
134
+ ref={ref}
135
+ type="button"
136
+ onClick={onClick}
137
+ disabled={disabled}
138
+ className={`${base} -translate-y-1 shadow-[0_5px_0_var(--edge),0_14px_22px_-10px_rgba(0,0,0,0.55)] transition-[transform,box-shadow] duration-200 hover:-translate-y-[6px] hover:shadow-[0_7px_0_var(--edge),0_18px_26px_-12px_rgba(0,0,0,0.55)] active:translate-y-0 active:shadow-[0_1px_0_var(--edge),0_4px_8px_-4px_rgba(0,0,0,0.4)] active:duration-75 ${className}`}
139
+ style={{ background: color, color: ink, "--edge": edge, ...smooth }}
140
+ {...rest}
141
+ >
142
+ <span aria-hidden="true" className="absolute inset-x-3 top-[3px] h-1/3 rounded-full bg-white/25 blur-[2px]" />
143
+ <span className="relative">{children}</span>
144
+ </button>
145
+ );
146
+ }
147
+
148
+ if (variant === "loading") {
149
+ const run = async (e) => {
150
+ if (status !== "idle") return;
151
+ setStatus("loading");
152
+ try {
153
+ const result = onClick?.(e);
154
+ await (result && typeof result.then === "function" ? result : new Promise((r) => setTimeout(r, 1400)));
155
+ setStatus("done");
156
+ } catch {
157
+ setStatus("idle");
158
+ return;
159
+ }
160
+ setTimeout(() => setStatus("idle"), 1600);
161
+ };
162
+ return (
163
+ <motion.button
164
+ ref={ref}
165
+ type="button"
166
+ layout
167
+ onClick={run}
168
+ disabled={disabled}
169
+ aria-busy={status === "loading"}
170
+ className={`${base} transition-[background-color,color] duration-300 active:scale-[0.97] ${className}`}
171
+ style={{ background: status === "done" ? "#22c55e" : color, color: status === "done" ? "#fff" : ink, borderRadius: 999, ...smooth }}
172
+ transition={{ layout: { type: "spring", stiffness: 420, damping: 34 } }}
173
+ {...rest}
174
+ >
175
+ <AnimatePresence mode="popLayout" initial={false}>
176
+ <motion.span
177
+ key={status}
178
+ className="flex items-center gap-2"
179
+ initial={{ opacity: 0, y: 12, scale: 0.9 }}
180
+ animate={{ opacity: 1, y: 0, scale: 1 }}
181
+ exit={{ opacity: 0, y: -12, scale: 0.9 }}
182
+ transition={{ duration: 0.25, ease: [0.22, 1, 0.36, 1] }}
183
+ >
184
+ {status === "idle" && children}
185
+ {status === "loading" && <Loader2 className="h-[1.15em] w-[1.15em] animate-spin" aria-label="Loading" />}
186
+ {status === "done" && (
187
+ <>
188
+ <Check className="h-[1.15em] w-[1.15em]" /> Done
189
+ </>
190
+ )}
191
+ </motion.span>
192
+ </AnimatePresence>
193
+ </motion.button>
194
+ );
195
+ }
196
+
197
+ if (variant === "ripple") {
198
+ return (
199
+ <button
200
+ ref={ref}
201
+ type="button"
202
+ disabled={disabled}
203
+ onPointerDown={(e) => {
204
+ const p = localPoint(e);
205
+ setRipples((list) => [...list, { id: performance.now(), x: p.x, y: p.y, d: Math.hypot(p.w, p.h) * 2.2 }]);
206
+ }}
207
+ onClick={onClick}
208
+ className={`${base} transition-[transform,filter] duration-300 hover:brightness-110 active:scale-[0.98] active:duration-100 ${className}`}
209
+ style={{ background: color, color: ink, ...smooth }}
210
+ {...rest}
211
+ >
212
+ {ripples.map((r) => (
213
+ <motion.span
214
+ key={r.id}
215
+ aria-hidden="true"
216
+ className="pointer-events-none absolute rounded-full will-change-transform"
217
+ style={{ left: r.x - r.d / 2, top: r.y - r.d / 2, width: r.d, height: r.d, background: ink }}
218
+ initial={{ scale: 0, opacity: 0.28 }}
219
+ animate={{ scale: 1, opacity: 0 }}
220
+ transition={{ duration: 0.75, ease: [0.22, 1, 0.36, 1] }}
221
+ onAnimationComplete={() => setRipples((list) => list.filter((x) => x.id !== r.id))}
222
+ />
223
+ ))}
224
+ <span className="relative">{children}</span>
225
+ </button>
226
+ );
227
+ }
228
+
229
+ // arrow
230
+ return (
231
+ <button
232
+ ref={ref}
233
+ type="button"
234
+ onClick={onClick}
235
+ disabled={disabled}
236
+ className={`group ${base} pr-1.5 transition-transform duration-300 active:scale-[0.97] active:duration-100 ${className}`}
237
+ style={{ background: color, color: ink, ...smooth }}
238
+ {...rest}
239
+ >
240
+ <span className="relative transition-transform duration-500 group-hover:-translate-x-1" style={smooth}>
241
+ {children}
242
+ </span>
243
+ <span className="relative grid aspect-square h-[calc(100%-0.5rem)] place-items-center overflow-hidden rounded-full transition-transform duration-500 group-hover:scale-105" style={{ background: ink, color, ...smooth }}>
244
+ <ArrowRight className="absolute h-[1.1em] w-[1.1em] transition-transform duration-500 group-hover:translate-x-[180%]" style={smooth} />
245
+ <ArrowRight className="absolute h-[1.1em] w-[1.1em] -translate-x-[180%] transition-transform duration-500 group-hover:translate-x-0" style={smooth} />
246
+ </span>
247
+ </button>
248
+ );
249
+ };
250
+
251
+ export default AnimatedButton;
@@ -0,0 +1,99 @@
1
+ import React, { useId, useRef, useState } from "react";
2
+ import { AnimatePresence, motion } from "framer-motion";
3
+
4
+ /*
5
+ * Animated Tabs: a pill slides to the chosen tab and the panel below
6
+ * cross-fades in the direction you moved. Arrow keys, Home and End work.
7
+ * Styles: "pill" (filled), "underline" or "segmented".
8
+ */
9
+
10
+ const DEFAULT_TABS = [
11
+ { id: "overview", label: "Overview", content: "A quick look at everything in one place — usage, limits and the latest activity." },
12
+ { id: "analytics", label: "Analytics", content: "Visitors, conversions and retention, updated every few seconds with zero setup." },
13
+ { id: "reports", label: "Reports", content: "Export weekly PDF reports or schedule them to land in your team's inbox." },
14
+ { id: "settings", label: "Settings", content: "Workspace name, members, billing and the danger zone — all in one tidy screen." },
15
+ ];
16
+
17
+ const AnimatedTabs = ({ tabs = DEFAULT_TABS, defaultTab, variant = "pill", accentColor = "#a3e635", theme = "dark", onChange, className = "" }) => {
18
+ const id = useId();
19
+ const [active, setActive] = useState(defaultTab ?? tabs[0]?.id);
20
+ const [dir, setDir] = useState(1);
21
+ const refs = useRef([]);
22
+ const dark = theme !== "light";
23
+ const index = tabs.findIndex((t) => t.id === active);
24
+ const current = tabs[index];
25
+
26
+ const select = (i) => {
27
+ const t = tabs[(i + tabs.length) % tabs.length];
28
+ setDir(tabs.indexOf(t) > index ? 1 : -1);
29
+ setActive(t.id);
30
+ refs.current[tabs.indexOf(t)]?.focus();
31
+ onChange?.(t.id);
32
+ };
33
+ const onKeyDown = (e) => {
34
+ const map = { ArrowRight: index + 1, ArrowLeft: index - 1, Home: 0, End: tabs.length - 1 };
35
+ if (map[e.key] === undefined) return;
36
+ e.preventDefault();
37
+ select(map[e.key]);
38
+ };
39
+
40
+ const list = {
41
+ pill: `rounded-full p-1 ${dark ? "bg-white/[0.06]" : "bg-black/[0.05]"}`,
42
+ segmented: `rounded-xl p-1 ${dark ? "bg-white/[0.06] ring-1 ring-white/10" : "bg-black/[0.04] ring-1 ring-black/10"}`,
43
+ underline: `border-b ${dark ? "border-white/10" : "border-black/10"}`,
44
+ }[variant];
45
+
46
+ return (
47
+ <div className={`w-full max-w-xl ${dark ? "text-white" : "text-neutral-900"} ${className}`}>
48
+ <div role="tablist" aria-orientation="horizontal" onKeyDown={onKeyDown} className={`relative flex overflow-x-auto [scrollbar-width:none] ${list}`}>
49
+ {tabs.map((t, i) => {
50
+ const on = t.id === active;
51
+ return (
52
+ <button
53
+ key={t.id}
54
+ ref={(el) => (refs.current[i] = el)}
55
+ role="tab"
56
+ id={`${id}-tab-${t.id}`}
57
+ aria-selected={on}
58
+ aria-controls={`${id}-panel`}
59
+ tabIndex={on ? 0 : -1}
60
+ onClick={() => select(i)}
61
+ className={`relative flex-1 whitespace-nowrap px-4 py-2 text-[0.88rem] font-medium outline-none transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-current/40 ${variant === "underline" ? "pb-3" : variant === "pill" ? "rounded-full" : "rounded-lg"} ${on ? (variant === "pill" ? "text-neutral-950" : "") : dark ? "text-white/55 hover:text-white" : "text-neutral-500 hover:text-neutral-900"}`}
62
+ >
63
+ {on && (
64
+ <motion.span
65
+ layoutId={`${id}-indicator`}
66
+ className={`absolute ${variant === "underline" ? "inset-x-2 -bottom-px h-[2px] rounded-full" : `inset-0 ${variant === "pill" ? "rounded-full" : "rounded-lg"}`}`}
67
+ style={{
68
+ background: variant === "segmented" ? (dark ? "rgba(255,255,255,0.1)" : "#fff") : accentColor,
69
+ boxShadow: variant === "segmented" ? (dark ? "inset 0 1px 0 rgba(255,255,255,0.08)" : "0 1px 3px rgba(0,0,0,0.12)") : undefined,
70
+ }}
71
+ transition={{ type: "spring", stiffness: 480, damping: 36 }}
72
+ />
73
+ )}
74
+ <span className="relative">{t.label}</span>
75
+ </button>
76
+ );
77
+ })}
78
+ </div>
79
+ <div id={`${id}-panel`} role="tabpanel" aria-labelledby={`${id}-tab-${active}`} className="relative mt-4 min-h-[6rem] overflow-hidden">
80
+ <AnimatePresence mode="popLayout" initial={false} custom={dir}>
81
+ <motion.div
82
+ key={active}
83
+ custom={dir}
84
+ initial={{ opacity: 0, x: dir * 24, filter: "blur(4px)" }}
85
+ animate={{ opacity: 1, x: 0, filter: "blur(0px)" }}
86
+ exit={{ opacity: 0, x: dir * -24, filter: "blur(4px)" }}
87
+ transition={{ duration: 0.28, ease: [0.22, 1, 0.36, 1] }}
88
+ className={`rounded-2xl p-5 text-[0.92rem] leading-relaxed ${dark ? "bg-white/[0.04] text-white/75" : "bg-black/[0.03] text-neutral-600"}`}
89
+ >
90
+ <p className={`mb-1.5 text-[1rem] font-semibold ${dark ? "text-white" : "text-neutral-900"}`}>{current?.label}</p>
91
+ {current?.content}
92
+ </motion.div>
93
+ </AnimatePresence>
94
+ </div>
95
+ </div>
96
+ );
97
+ };
98
+
99
+ export default AnimatedTabs;
@@ -0,0 +1,60 @@
1
+ import React from "react";
2
+ import { motion, useReducedMotion } from "framer-motion";
3
+
4
+ /*
5
+ * Blur Reveal Text: words (or letters) rise out of a blur one after another
6
+ * when the text scrolls into view. An optional highlight word gets the accent.
7
+ */
8
+
9
+ const BlurRevealText = ({
10
+ text = "Design that moves people, built in minutes.",
11
+ as: Tag = "p",
12
+ by = "word",
13
+ stagger = 0.06,
14
+ duration = 0.7,
15
+ blur = 12,
16
+ highlight = [],
17
+ accentColor = "#a3e635",
18
+ once = true,
19
+ className = "",
20
+ }) => {
21
+ const reduced = useReducedMotion();
22
+ const words = text.split(" ");
23
+ let index = 0;
24
+ const MotionTag = motion[Tag] ?? motion.p;
25
+
26
+ const piece = (content, key, accent) => {
27
+ const delay = index++ * stagger;
28
+ return (
29
+ <motion.span
30
+ key={key}
31
+ className="inline-block will-change-[filter,transform]"
32
+ style={accent ? { color: accentColor } : undefined}
33
+ variants={{
34
+ hidden: reduced ? { opacity: 0 } : { opacity: 0, y: "0.35em", filter: `blur(${blur}px)` },
35
+ shown: { opacity: 1, y: 0, filter: "blur(0px)", transition: { duration, delay, ease: [0.22, 1, 0.36, 1] } },
36
+ }}
37
+ >
38
+ {content}
39
+ </motion.span>
40
+ );
41
+ };
42
+
43
+ return (
44
+ <MotionTag aria-label={text} className={className} initial="hidden" whileInView="shown" viewport={{ once, amount: 0.4 }}>
45
+ {words.map((word, w) => {
46
+ const accent = highlight.some((h) => h.toLowerCase() === word.replace(/[^\p{L}\p{N}'-]/gu, "").toLowerCase());
47
+ return (
48
+ <React.Fragment key={w}>
49
+ <span aria-hidden="true" className="inline-block whitespace-nowrap">
50
+ {by === "letter" ? [...word].map((ch, c) => piece(ch, c, accent)) : piece(word, 0, accent)}
51
+ </span>
52
+ {w < words.length - 1 && " "}
53
+ </React.Fragment>
54
+ );
55
+ })}
56
+ </MotionTag>
57
+ );
58
+ };
59
+
60
+ export default BlurRevealText;
@@ -0,0 +1,222 @@
1
+ import React, { useEffect, useRef, useState } from "react";
2
+ import { AnimatePresence, animate, motion, useAnimationFrame, useMotionValue, useMotionValueEvent, useReducedMotion, useTransform } from "framer-motion";
3
+ import { ChevronLeft, ChevronRight } from "lucide-react";
4
+
5
+ /*
6
+ * 3D Carousel: cards stand on a ring that turns in 3D. Drag (or swipe) to
7
+ * spin it, let go and it settles on the nearest card. It turns slowly on its
8
+ * own, and a click on a card or the arrows brings that card to the front.
9
+ */
10
+
11
+ const IK = "https://ik.imagekit.io/ny6yinyut/apexui/assets";
12
+ const img = (name) => `${IK}/${name}?tr=f-auto,q-80,w-480`;
13
+
14
+ const DEFAULT_ITEMS = [
15
+ { image: img("ironman.png"), title: "Iron Man", subtitle: "Genius, billionaire" },
16
+ { image: img("spiderman.png"), title: "Spider-Man", subtitle: "Friendly neighbour" },
17
+ { image: img("captainamerica.png"), title: "Captain America", subtitle: "The first avenger" },
18
+ { image: img("hulk.png"), title: "Hulk", subtitle: "Always angry" },
19
+ { image: img("black-panther.png"), title: "Black Panther", subtitle: "King of Wakanda" },
20
+ { image: img("doctorStrange.png"), title: "Doctor Strange", subtitle: "Sorcerer supreme" },
21
+ { image: img("thanos.png"), title: "Thanos", subtitle: "Inevitable" },
22
+ ];
23
+
24
+ const settle = { type: "spring", stiffness: 140, damping: 22, mass: 1 };
25
+ const mod = (n, m) => ((n % m) + m) % m;
26
+
27
+ function Card({ item, index, step, radius, rotation, onSelect }) {
28
+ // Cards turned away from the viewer get darker.
29
+ const brightness = useTransform(rotation, (r) => {
30
+ const facing = Math.cos(((index * step + r) * Math.PI) / 180);
31
+ return `brightness(${0.35 + 0.65 * Math.max(0, facing)})`;
32
+ });
33
+
34
+ return (
35
+ <motion.button
36
+ type="button"
37
+ aria-label={item.title}
38
+ onClick={() => onSelect(index)}
39
+ className="absolute inset-0 overflow-hidden rounded-[1.1rem] bg-neutral-900 text-left text-white shadow-[0_24px_50px_-20px_rgba(0,0,0,0.6)] outline-none [backface-visibility:hidden] focus-visible:ring-2 focus-visible:ring-white/70"
40
+ style={{ transform: `rotateY(${index * step}deg) translateZ(${radius}px)`, filter: brightness }}
41
+ >
42
+ <img src={item.image} alt="" draggable="false" className="pointer-events-none h-full w-full select-none object-cover" />
43
+ <span className="pointer-events-none absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/85 via-black/40 to-transparent px-3.5 pb-3 pt-10">
44
+ <span className="block truncate text-[0.95rem] font-semibold">{item.title}</span>
45
+ {item.subtitle && <span className="block truncate text-[0.75rem] text-white/65">{item.subtitle}</span>}
46
+ </span>
47
+ <span className="pointer-events-none absolute inset-0 rounded-[1.1rem] ring-1 ring-inset ring-white/10" />
48
+ </motion.button>
49
+ );
50
+ }
51
+
52
+ const Carousel3D = ({
53
+ items = DEFAULT_ITEMS,
54
+ cardWidth = 11,
55
+ aspectRatio = "3 / 4",
56
+ gap = 1,
57
+ tilt = -6,
58
+ perspective = 1400,
59
+ autoRotate = true,
60
+ autoRotateSpeed = 10,
61
+ pauseOnHover = true,
62
+ showControls = true,
63
+ onActiveChange,
64
+ className = "",
65
+ }) => {
66
+ const count = Math.max(items.length, 1);
67
+ const step = 360 / count;
68
+ const rotation = useMotionValue(0);
69
+ const reduced = useReducedMotion();
70
+ const cardRef = useRef(null);
71
+ const drag = useRef(null);
72
+ const dragged = useRef(false);
73
+ const busy = useRef(false);
74
+ const [radius, setRadius] = useState(300);
75
+ const [hovered, setHovered] = useState(false);
76
+ const [active, setActive] = useState(0);
77
+ const onActiveRef = useRef(onActiveChange);
78
+ onActiveRef.current = onActiveChange;
79
+
80
+ // Ring radius that fits the cards side by side, from their real width.
81
+ useEffect(() => {
82
+ const el = cardRef.current;
83
+ if (!el) return undefined;
84
+ const measure = () => {
85
+ const w = el.offsetWidth;
86
+ const gapPx = gap * parseFloat(getComputedStyle(document.documentElement).fontSize || "16");
87
+ setRadius(Math.round(w / 2 / Math.tan(Math.PI / count) + gapPx));
88
+ };
89
+ measure();
90
+ const ro = new ResizeObserver(measure);
91
+ ro.observe(el);
92
+ return () => ro.disconnect();
93
+ }, [count, gap]);
94
+
95
+ useMotionValueEvent(rotation, "change", (r) => {
96
+ const next = mod(Math.round(-r / step), count);
97
+ setActive((prev) => {
98
+ if (prev !== next) onActiveRef.current?.(next, items[next]);
99
+ return next;
100
+ });
101
+ });
102
+
103
+ // Slow idle spin.
104
+ useAnimationFrame((_, delta) => {
105
+ if (!autoRotate || reduced || drag.current || busy.current || (pauseOnHover && hovered)) return;
106
+ rotation.set(rotation.get() - (autoRotateSpeed * Math.min(delta, 64)) / 1000);
107
+ });
108
+
109
+ const spinTo = (target, velocity = 0) => {
110
+ busy.current = true;
111
+ animate(rotation, target, { ...settle, velocity, onComplete: () => (busy.current = false) });
112
+ };
113
+
114
+ const goTo = (index) => {
115
+ const current = rotation.get();
116
+ const diff = mod(-index * step - current + 180, 360) - 180;
117
+ spinTo(current + diff);
118
+ };
119
+
120
+ const onPointerDown = (e) => {
121
+ if (e.button !== undefined && e.button !== 0) return;
122
+ rotation.stop();
123
+ busy.current = false;
124
+ drag.current = { x: e.clientX, start: rotation.get(), moved: false, id: e.pointerId };
125
+ };
126
+ const onPointerMove = (e) => {
127
+ const d = drag.current;
128
+ if (!d) return;
129
+ const dx = e.clientX - d.x;
130
+ if (!d.moved && Math.abs(dx) > 5) {
131
+ d.moved = true;
132
+ e.currentTarget.setPointerCapture?.(d.id);
133
+ }
134
+ if (d.moved) rotation.set(d.start + dx * (180 / (Math.PI * radius)));
135
+ };
136
+ const onPointerUp = () => {
137
+ const d = drag.current;
138
+ drag.current = null;
139
+ dragged.current = Boolean(d?.moved);
140
+ if (!d?.moved) return;
141
+ setTimeout(() => (dragged.current = false), 0);
142
+ const velocity = rotation.getVelocity();
143
+ const projected = rotation.get() + velocity * 0.25;
144
+ spinTo(Math.round(projected / step) * step, velocity);
145
+ };
146
+
147
+ const onKeyDown = (e) => {
148
+ if (e.key === "ArrowRight") goTo(active + 1);
149
+ if (e.key === "ArrowLeft") goTo(active - 1);
150
+ };
151
+
152
+ const current = items[active];
153
+ const roundBtn = "grid h-10 w-10 place-items-center rounded-full border backdrop-blur transition hover:scale-105 active:scale-95";
154
+ const btnStyle = { borderColor: "color-mix(in srgb, currentColor 18%, transparent)", background: "color-mix(in srgb, currentColor 6%, transparent)" };
155
+
156
+ return (
157
+ <section
158
+ aria-roledescription="carousel"
159
+ aria-label="3D carousel"
160
+ className={`flex w-full flex-col items-center ${className}`}
161
+ onPointerEnter={(e) => e.pointerType === "mouse" && setHovered(true)}
162
+ onPointerLeave={() => setHovered(false)}
163
+ >
164
+ <div
165
+ tabIndex={0}
166
+ onKeyDown={onKeyDown}
167
+ onPointerDown={onPointerDown}
168
+ onPointerMove={onPointerMove}
169
+ onPointerUp={onPointerUp}
170
+ onPointerCancel={onPointerUp}
171
+ onClickCapture={(e) => {
172
+ // A drag should not count as a click on the card under the pointer.
173
+ if (dragged.current) e.stopPropagation();
174
+ }}
175
+ className="relative flex w-full cursor-grab touch-pan-y select-none items-center justify-center rounded-2xl py-8 outline-none active:cursor-grabbing focus-visible:ring-2 focus-visible:ring-current/30"
176
+ style={{ perspective: `${perspective}px` }}
177
+ >
178
+ {/* Push the ring back by its radius so the front card sits at its real size. */}
179
+ <div style={{ transformStyle: "preserve-3d", transform: `translateZ(-${radius}px) rotateX(${tilt}deg)` }}>
180
+ <motion.div
181
+ ref={cardRef}
182
+ className="relative"
183
+ style={{ width: `min(${cardWidth}rem, 40vw)`, aspectRatio, transformStyle: "preserve-3d", rotateY: rotation }}
184
+ >
185
+ {items.map((item, i) => (
186
+ <Card key={i} item={item} index={i} step={step} radius={radius} rotation={rotation} onSelect={goTo} />
187
+ ))}
188
+ </motion.div>
189
+ </div>
190
+ </div>
191
+
192
+ {showControls && (
193
+ <div className="mt-2 flex items-center gap-5">
194
+ <button type="button" aria-label="Previous" onClick={() => goTo(active - 1)} className={roundBtn} style={btnStyle}>
195
+ <ChevronLeft className="h-5 w-5" />
196
+ </button>
197
+ <div className="min-w-[9rem] text-center" aria-live="polite">
198
+ <AnimatePresence mode="wait" initial={false}>
199
+ <motion.div
200
+ key={active}
201
+ initial={{ opacity: 0, y: 6 }}
202
+ animate={{ opacity: 1, y: 0 }}
203
+ exit={{ opacity: 0, y: -6 }}
204
+ transition={{ duration: 0.18 }}
205
+ >
206
+ <p className="text-[0.95rem] font-semibold">{current?.title}</p>
207
+ <p className="text-[0.75rem] opacity-60">
208
+ {active + 1} / {count}
209
+ </p>
210
+ </motion.div>
211
+ </AnimatePresence>
212
+ </div>
213
+ <button type="button" aria-label="Next" onClick={() => goTo(active + 1)} className={roundBtn} style={btnStyle}>
214
+ <ChevronRight className="h-5 w-5" />
215
+ </button>
216
+ </div>
217
+ )}
218
+ </section>
219
+ );
220
+ };
221
+
222
+ export default Carousel3D;