"use client";
import * as React from "react";
import { useReducedMotion } from "motion/react";
import { ChevronLeft, ChevronRight, Pause, Play } from "lucide-react";
import { cn } from "@/lib/utils";
export interface CarouselItem {
id: string;
title: string;
subtitle?: string;
tag?: string;
/** Two colours for the card art. */
colors: [string, string];
}
export interface Infinite3DCarouselProps {
items?: CarouselItem[];
/** Card width in px (height follows a 4:5 ratio). Shrinks on narrow screens. */
cardWidth?: number;
/** Auto-rotate interval in ms; 0 disables. */
autoplay?: number;
onSelect?: (item: CarouselItem, index: number) => void;
onIndexChange?: (index: number) => void;
renderCard?: (item: CarouselItem, isFront: boolean) => React.ReactNode;
label?: string;
className?: string;
}
const DEFAULT_ITEMS: CarouselItem[] = [
{ id: "aurora", title: "Aurora", subtitle: "Polar light studies", tag: "Series 01", colors: ["#7c3aed", "#22d3ee"] },
{ id: "dune", title: "Dune", subtitle: "Warm sand gradients", tag: "Series 02", colors: ["#f59e0b", "#ef4444"] },
{ id: "tide", title: "Tide", subtitle: "Deep ocean blues", tag: "Series 03", colors: ["#0ea5e9", "#1e3a8a"] },
{ id: "ember", title: "Ember", subtitle: "Glowing coal tones", tag: "Series 04", colors: ["#f97316", "#7f1d1d"] },
{ id: "moss", title: "Moss", subtitle: "Forest floor greens", tag: "Series 05", colors: ["#84cc16", "#065f46"] },
{ id: "nebula", title: "Nebula", subtitle: "Cosmic dust clouds", tag: "Series 06", colors: ["#ec4899", "#4c1d95"] },
{ id: "coral", title: "Coral", subtitle: "Reef at noon", tag: "Series 07", colors: ["#fb7185", "#f59e0b"] },
{ id: "glacier", title: "Glacier", subtitle: "Ice and silence", tag: "Series 08", colors: ["#a5f3fc", "#6366f1"] },
];
const norm = (a: number) => ((((a + 180) % 360) + 360) % 360) - 180;
export function Infinite3DCarousel({
items = DEFAULT_ITEMS,
cardWidth = 210,
autoplay = 3500,
onSelect,
onIndexChange,
renderCard,
label = "Collections",
className,
}: Infinite3DCarouselProps) {
const reduce = useReducedMotion() ?? false;
const n = items.length;
const step = 360 / n;
const rootRef = React.useRef<HTMLDivElement>(null);
const ringRef = React.useRef<HTMLDivElement>(null);
const cardRefs = React.useRef<(HTMLDivElement | null)[]>([]);
const btnRefs = React.useRef<(HTMLButtonElement | null)[]>([]);
const [width, setWidth] = React.useState(cardWidth);
const [front, setFront] = React.useState(0);
const [playing, setPlaying] = React.useState(autoplay > 0);
const [hovered, setHovered] = React.useState(false);
const [visible, setVisible] = React.useState(true);
const focusAfter = React.useRef(false);
const idxCb = React.useRef(onIndexChange);
React.useEffect(() => {
idxCb.current = onIndexChange;
}, [onIndexChange]);
const sim = React.useRef({ angle: 0, target: 0, vel: 0, dragging: false, startX: 0, startAngle: 0, lastX: 0, lastT: 0, moved: 0 });
// Responsive card width.
React.useEffect(() => {
const el = rootRef.current;
if (!el) return;
const ro = new ResizeObserver(([e]) => setWidth(Math.min(cardWidth, Math.max(140, e.contentRect.width * 0.42))));
ro.observe(el);
const io = new IntersectionObserver(([e]) => setVisible(e.isIntersecting));
io.observe(el);
return () => {
ro.disconnect();
io.disconnect();
};
}, [cardWidth]);
const radius = Math.round(width / 2 / Math.tan(Math.PI / n) + width * 0.18);
const height = Math.round(width * 1.25);
const goTo = React.useCallback(
(i: number) => {
const s = sim.current;
// Rotate the shortest way to put item i in front.
const cur = -s.target / step;
const diff = ((((i - cur) % n) + n + n / 2) % n) - n / 2;
s.target = -(cur + Math.round(diff)) * step;
s.vel = 0;
},
[n, step],
);
// Physics loop.
React.useEffect(() => {
if (!visible) return;
let raf = 0;
let lastFront = -1;
let prev = performance.now();
const loop = (now: number) => {
const f = Math.min(3, (now - prev) / 16.67);
prev = now;
const s = sim.current;
if (!s.dragging) {
if (Math.abs(s.vel) > 0.05) {
s.angle += s.vel * f;
s.vel *= Math.pow(0.93, f);
s.target = Math.round(s.angle / step) * step;
} else {
s.vel = 0;
s.angle += (s.target - s.angle) * (reduce ? 1 : 1 - Math.pow(1 - 0.12, f));
}
}
if (ringRef.current) ringRef.current.style.transform = `translateZ(${-radius}px) rotateY(${s.angle}deg)`;
for (let i = 0; i < n; i++) {
const el = cardRefs.current[i];
if (!el) continue;
const rel = Math.abs(norm(i * step + s.angle));
const t = Math.max(0, 1 - rel / 180);
el.style.opacity = `${0.15 + 0.85 * Math.pow(t, 1.6)}`;
el.style.filter = rel < step / 2 ? "none" : `saturate(${0.5 + t * 0.5}) brightness(${0.55 + t * 0.45})`;
}
const fi = (((Math.round(-s.angle / step) % n) + n) % n) as number;
if (fi !== lastFront) {
lastFront = fi;
setFront(fi);
}
raf = requestAnimationFrame(loop);
};
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
}, [visible, radius, n, step, reduce]);
React.useEffect(() => {
idxCb.current?.(front);
if (focusAfter.current) {
focusAfter.current = false;
btnRefs.current[front]?.focus({ preventScroll: true });
}
}, [front]);
// Autoplay.
React.useEffect(() => {
if (!playing || hovered || !visible || !autoplay || reduce) return;
const id = setInterval(() => {
const s = sim.current;
if (s.dragging) return;
s.target -= step;
}, autoplay);
return () => clearInterval(id);
}, [playing, hovered, visible, autoplay, step, reduce]);
const onPointerDown = (e: React.PointerEvent) => {
if (e.button !== 0) return;
const s = sim.current;
s.dragging = true;
s.startX = e.clientX;
s.lastX = e.clientX;
s.lastT = performance.now();
s.startAngle = s.angle;
s.vel = 0;
s.moved = 0;
e.currentTarget.setPointerCapture(e.pointerId);
};
const onPointerMove = (e: React.PointerEvent) => {
const s = sim.current;
if (!s.dragging) return;
const dx = e.clientX - s.startX;
s.moved = Math.max(s.moved, Math.abs(dx));
const degPerPx = step / (width * 1.1);
s.angle = s.startAngle + dx * degPerPx;
const now = performance.now();
const dt = Math.max(1, now - s.lastT);
s.vel = ((e.clientX - s.lastX) * degPerPx * 16.67) / dt;
s.lastX = e.clientX;
s.lastT = now;
};
const onPointerUp = () => {
const s = sim.current;
if (!s.dragging) return;
s.dragging = false;
if (performance.now() - s.lastT > 80) s.vel = 0;
s.target = Math.round(s.angle / step) * step;
};
const move = (dir: 1 | -1, focus = false) => {
focusAfter.current = focus;
sim.current.target -= dir * step;
sim.current.vel = 0;
};
return (
<div
ref={rootRef}
role="region"
aria-roledescription="carousel"
aria-label={label}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
className={cn("relative w-full select-none", className)}
>
<div
className="relative mx-auto cursor-grab touch-pan-y overflow-hidden [perspective:1100px] active:cursor-grabbing"
style={{ height: height + 100 }}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
>
<div
ref={ringRef}
className="absolute top-[30px] left-1/2 [transform-style:preserve-3d]"
style={{ width, height, marginLeft: -width / 2, transform: `translateZ(${-radius}px)` }}
>
{items.map((it, i) => {
const isFront = i === front;
return (
<div
key={it.id}
ref={(el) => {
cardRefs.current[i] = el;
}}
className="absolute inset-0 [backface-visibility:hidden]"
style={{ transform: `rotateY(${i * step}deg) translateZ(${radius}px)` }}
>
<button
ref={(el) => {
btnRefs.current[i] = el;
}}
type="button"
tabIndex={isFront ? 0 : -1}
aria-hidden={!isFront}
aria-label={`${it.title}${it.subtitle ? `, ${it.subtitle}` : ""}. ${i + 1} of ${n}`}
onClick={() => {
if (sim.current.moved > 6) return;
if (isFront) onSelect?.(it, i);
else goTo(i);
}}
onKeyDown={(e) => {
if (e.key === "ArrowRight") {
e.preventDefault();
move(1, true);
} else if (e.key === "ArrowLeft") {
e.preventDefault();
move(-1, true);
}
}}
className={cn(
"group relative block size-full rounded-3xl text-left transition-[box-shadow,transform] duration-300 outline-none",
isFront ? "scale-100 shadow-[0_30px_60px_-25px_rgba(0,0,0,0.55)]" : "scale-[0.94] shadow-lg",
)}
>
{renderCard ? renderCard(it, isFront) : <DefaultCard item={it} />}
<span aria-hidden className="pointer-events-none absolute inset-0 rounded-3xl opacity-0 ring-[3px] ring-white ring-inset transition-opacity group-focus-visible:opacity-100" />
</button>
</div>
);
})}
</div>
{/* Floor reflection */}
<div aria-hidden className="pointer-events-none absolute inset-x-[25%] bottom-8 h-5 rounded-[50%] bg-foreground/10 blur-xl" />
</div>
<div className="mt-2 flex items-center justify-center gap-3">
<NavButton label="Previous" onClick={() => move(-1)}>
<ChevronLeft className="size-4" />
</NavButton>
<div className="flex items-center gap-1.5" aria-hidden>
{items.map((it, i) => (
<span key={it.id} className={cn("h-1.5 rounded-full transition-all duration-300", i === front ? "w-5 bg-foreground" : "w-1.5 bg-foreground/25")} />
))}
</div>
<NavButton label="Next" onClick={() => move(1)}>
<ChevronRight className="size-4" />
</NavButton>
{autoplay > 0 && (
<NavButton label={playing ? "Pause rotation" : "Play rotation"} onClick={() => setPlaying((p) => !p)}>
{playing ? <Pause className="size-3.5" /> : <Play className="size-3.5" />}
</NavButton>
)}
</div>
<p className="sr-only" aria-live="polite">
{items[front]?.title}, {front + 1} of {n}
</p>
</div>
);
}
function NavButton({ children, label, onClick }: { children: React.ReactNode; label: string; onClick: () => void }) {
return (
<button
type="button"
aria-label={label}
onClick={onClick}
className="grid size-8 place-items-center rounded-full border bg-card text-muted-foreground shadow-sm transition hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
>
{children}
</button>
);
}
function DefaultCard({ item }: { item: CarouselItem }) {
const [a, b] = item.colors;
const seed = [...item.id].reduce((s, c) => s + c.charCodeAt(0), 0);
return (
<span className="relative flex size-full flex-col overflow-hidden rounded-3xl p-4 text-white" style={{ background: `linear-gradient(150deg, ${a}, ${b})` }}>
<svg aria-hidden viewBox="0 0 100 100" preserveAspectRatio="xMidYMid slice" className="absolute inset-0 size-full opacity-60 mix-blend-soft-light">
<circle cx={20 + (seed % 50)} cy={25 + (seed % 20)} r="34" fill="white" fillOpacity="0.35" />
<circle cx={80 - (seed % 30)} cy={70} r="26" fill="black" fillOpacity="0.25" />
<path d={`M0 ${70 + (seed % 12)} Q 50 ${40 + (seed % 25)} 100 ${75 - (seed % 15)} L100 100 L0 100 Z`} fill="white" fillOpacity="0.3" />
</svg>
<span className="relative text-[10px] font-semibold tracking-[0.2em] uppercase opacity-80">{item.tag}</span>
<span className="relative mt-auto text-2xl font-semibold tracking-tight">{item.title}</span>
{item.subtitle && <span className="relative text-sm opacity-85">{item.subtitle}</span>}
</span>
);
}