"use client";
import * as React from "react";
import { animate, motion, useMotionValue, type AnimationPlaybackControls } from "motion/react";
function subscribeReducedMotion(callback: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", callback);
return () => mq.removeEventListener("change", callback);
}
/** SSR-safe: hydrates as "no preference", then follows the media query. */
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
export interface RingCarouselItem {
id: string;
content: React.ReactNode;
/** Short title used in announcements ("Item 3 of 8: title") and slide names. */
label: string;
}
export interface RingCarouselProps extends Omit<React.ComponentPropsWithoutRef<"section">, "children"> {
items: RingCarouselItem[];
/** Accessible name of the carousel. */
label?: string;
/** Card width in px; the ring radius is derived from it, the gap and the item count. */
cardWidth?: number;
/** Card height in px. Defaults to 1.3 × cardWidth. */
cardHeight?: number;
/** Space between neighbouring cards around the ring, in px. */
gap?: number;
/** CSS perspective in px — lower is more dramatic. */
perspective?: number;
/** Ring tilt in degrees; negative looks down onto the ring. */
tilt?: number;
/** Step to the next card on an interval (pauses on hover, focus, offscreen and hidden tabs). */
autoRotate?: boolean;
/** Auto-rotate interval in ms. */
interval?: number;
/** Front-card ring, focus and control colour (any CSS colour). */
accent?: string;
/** Previous / pause / next buttons and the counter. */
showControls?: boolean;
/** Initially centred card. */
defaultIndex?: number;
/** Called when the front card changes. */
onIndexChange?: (index: number) => void;
}
const SPRING = { type: "spring", stiffness: 150, damping: 24, mass: 1 } as const;
const mod = (n: number, m: number) => ((n % m) + m) % m;
function subscribeVisibility(callback: () => void) {
document.addEventListener("visibilitychange", callback);
return () => document.removeEventListener("visibilitychange", callback);
}
const iconButton =
"grid size-9 place-items-center rounded-full border border-zinc-900/12 bg-white text-zinc-700 outline-none transition-colors hover:border-zinc-900/25 hover:text-zinc-950 focus-visible:ring-2 focus-visible:ring-[var(--rc-accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-transparent dark:border-white/12 dark:bg-zinc-900 dark:text-zinc-300 dark:hover:border-white/25 dark:hover:text-white";
function Chevron({ dir }: { dir: "left" | "right" }) {
return (
<svg aria-hidden="true" viewBox="0 0 16 16" className="size-4" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<path d={dir === "left" ? "M10 3.5 5.5 8l4.5 4.5" : "M6 3.5 10.5 8 6 12.5"} />
</svg>
);
}
/**
* A ring of cards in CSS 3D: each card is rotated around a cylinder whose radius is computed
* from the card width and count. Drag to spin (with inertia and snap), use the buttons or the
* arrow keys, or let it step automatically. Only the front card is interactive; the rest are
* inert and hidden from assistive tech. Reduced motion swaps the ring for a flat scroller.
*/
export function RingCarousel({
items,
label = "Featured",
cardWidth = 220,
cardHeight: cardHeightProp,
gap = 28,
perspective = 1200,
tilt = -8,
autoRotate = true,
interval = 3200,
accent = "#FF7448",
showControls = true,
defaultIndex = 0,
onIndexChange,
className = "",
style,
...props
}: RingCarouselProps) {
const reduceMotion = usePrefersReducedMotion();
const n = items.length;
const step = n > 0 ? 360 / n : 360;
const cardHeight = cardHeightProp ?? Math.round(cardWidth * 1.3);
// Chord between neighbouring card centres = cardWidth + gap → r = (c / 2) / tan(π / n).
const radius = n > 1 ? Math.round((cardWidth + gap) / 2 / Math.tan(Math.PI / n)) : 0;
const startIndex = n ? mod(Math.round(defaultIndex), n) : 0;
// Tilting lifts the back of the ring above the front card: reserve that height (plus breathing room).
const verticalRoom = Math.round(2 * radius * Math.sin((Math.abs(tilt) * Math.PI) / 180) + 36);
const rootRef = React.useRef<HTMLElement>(null);
const stageRef = React.useRef<HTMLDivElement>(null);
const cardRefs = React.useRef<(HTMLDivElement | null)[]>([]);
const scrollerRef = React.useRef<HTMLUListElement>(null);
const angle = useMotionValue(-startIndex * step);
const anim = React.useRef<AnimationPlaybackControls | null>(null);
const target = React.useRef<number | null>(null);
const scale = React.useRef(1);
const indexRef = React.useRef(startIndex);
const onIndexChangeRef = React.useRef(onIndexChange);
const drag = React.useRef<{ id: number; x: number; t: number; startX: number; start: number; v: number; moved: boolean } | null>(null);
const suppressClick = React.useRef(false);
const [index, setIndex] = React.useState(startIndex);
const [dragging, setDragging] = React.useState(false);
const [hovering, setHovering] = React.useState(false);
const [focusWithin, setFocusWithin] = React.useState(false);
const [userPaused, setUserPaused] = React.useState(false);
const [inView, setInView] = React.useState(true);
const hidden = React.useSyncExternalStore(subscribeVisibility, () => document.hidden, () => false);
const front = n ? Math.min(index, n - 1) : 0;
const flat = reduceMotion;
const running = autoRotate && !flat && n > 1 && !userPaused && !hovering && !focusWithin && !dragging && inView && !hidden;
React.useEffect(() => {
onIndexChangeRef.current = onIndexChange;
});
// Track the front card and depth shading from the angle — DOM writes, no per-frame state.
React.useEffect(() => {
if (!n) return;
const update = (a: number) => {
cardRefs.current.forEach((el, i) => {
if (!el) return;
const rel = ((i * step + a) * Math.PI) / 180;
el.style.setProperty("--rc-dim", ((1 - Math.cos(rel)) * 0.3).toFixed(3));
});
const idx = mod(Math.round(-a / step), n);
if (idx !== indexRef.current) {
indexRef.current = idx;
setIndex(idx);
onIndexChangeRef.current?.(idx);
}
};
// When the item count changes, re-seat the ring on the (clamped) current card.
const seat = -Math.min(indexRef.current, n - 1) * step;
if (Math.abs(mod(angle.get() - seat + 180, 360) - 180) > 0.01) angle.set(seat);
update(angle.get());
return angle.on("change", update);
}, [angle, step, n]);
// Pause when offscreen; fit the ring to narrow containers by scaling the scene.
React.useEffect(() => {
const root = rootRef.current;
const stage = stageRef.current;
if (!root) return;
const io = new IntersectionObserver(([entry]) => setInView(entry.isIntersecting));
io.observe(root);
const ro = new ResizeObserver(() => {
if (!stage) return;
const s = Math.min(1, stage.clientWidth / (radius * 1.3 + cardWidth + 32));
scale.current = s;
root.style.setProperty("--rc-scale", s.toFixed(3));
});
if (stage) ro.observe(stage);
return () => {
io.disconnect();
ro.disconnect();
};
}, [radius, cardWidth, flat]);
const spinTo = (to: number, velocity = 0) => {
anim.current?.stop();
target.current = to;
anim.current = animate(angle, to, { ...SPRING, velocity });
};
/** Steps by whole cards from the current target (so quick repeated presses accumulate). */
const stepBy = (delta: number) => {
const base = target.current ?? angle.get();
spinTo(Math.round(base / step) * step - delta * step);
};
const goTo = (i: number) => {
const base = -i * step;
const current = angle.get();
spinTo(base + 360 * Math.round((current - base) / 360));
};
// Auto-rotation: one card per interval while nothing asks it to pause.
React.useEffect(() => {
if (!running) return;
const t = setInterval(() => {
anim.current?.stop();
const base = target.current ?? angle.get();
const to = Math.round(base / step) * step - step;
target.current = to;
anim.current = animate(angle, to, SPRING);
}, Math.max(1200, interval));
return () => clearInterval(t);
}, [running, interval, angle, step]);
React.useEffect(() => () => anim.current?.stop(), []);
/* --------------------------------- Drag --------------------------------- */
const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
if (e.button !== 0 || n < 2) return;
suppressClick.current = false;
drag.current = { id: e.pointerId, x: e.clientX, t: e.timeStamp, startX: e.clientX, start: angle.get(), v: 0, moved: false };
};
const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
const d = drag.current;
if (!d || d.id !== e.pointerId) return;
if (!d.moved) {
if (Math.abs(e.clientX - d.startX) < 6) return;
// Only now capture, so a plain click still reaches the card underneath.
d.moved = true;
e.currentTarget.setPointerCapture(e.pointerId);
anim.current?.stop();
target.current = null;
d.start = angle.get();
d.startX = e.clientX;
setDragging(true);
}
const degPerPx = step / ((cardWidth + gap) * scale.current);
const dt = Math.max(1, e.timeStamp - d.t) / 1000;
d.v = d.v * 0.6 + (((e.clientX - d.x) * degPerPx) / dt) * 0.4;
d.x = e.clientX;
d.t = e.timeStamp;
angle.set(d.start + (e.clientX - d.startX) * degPerPx);
};
const endDrag = (e: React.PointerEvent<HTMLDivElement>) => {
const d = drag.current;
if (!d || d.id !== e.pointerId) return;
drag.current = null;
if (!d.moved) return;
suppressClick.current = true;
setDragging(false);
const velocity = e.timeStamp - d.t > 80 ? 0 : d.v; // a pause before release means no flick
const a = angle.get();
// Project the flick a little, snap to a card, and let the spring carry the velocity.
const maxSteps = Math.max(1, Math.floor(n / 2));
const projected = Math.max(-maxSteps * step, Math.min(maxSteps * step, velocity * 0.18));
spinTo(Math.round((a + projected) / step) * step, velocity);
};
const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.target !== e.currentTarget) return;
const actions: Record<string, () => void> = {
ArrowLeft: () => stepBy(-1),
ArrowRight: () => stepBy(1),
Home: () => goTo(0),
End: () => goTo(n - 1),
};
const action = actions[e.key];
if (!action) return;
e.preventDefault();
action();
};
const scrollFlat = (dir: 1 | -1) => {
const el = scrollerRef.current;
if (el) el.scrollLeft += dir * (cardWidth + gap);
};
if (!n) return null;
const current = items[front];
return (
<section
ref={rootRef}
aria-roledescription="carousel"
aria-label={label}
className={`relative w-full ${className}`}
style={{ ["--rc-accent" as string]: accent, ...style }}
onPointerEnter={(e) => e.pointerType === "mouse" && setHovering(true)}
onPointerLeave={() => setHovering(false)}
onFocus={(e) => {
// Keyboard focus pauses rotation; a mouse click on a button doesn't.
if (e.target.matches(":focus-visible")) setFocusWithin(true);
}}
onBlur={(e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setFocusWithin(false);
}}
{...props}
>
{flat ? (
<ul
ref={scrollerRef}
aria-label={`${label} — scroll horizontally`}
className="flex snap-x snap-mandatory overflow-x-auto overscroll-x-contain px-4 pb-3 [scrollbar-width:thin]"
style={{ gap }}
>
{items.map((item, i) => (
<li
key={item.id}
aria-label={`${i + 1} of ${n}: ${item.label}`}
className="shrink-0 snap-center overflow-hidden rounded-2xl border border-zinc-900/10 bg-white dark:border-white/10 dark:bg-zinc-900"
style={{ width: cardWidth, height: cardHeight }}
>
{item.content}
</li>
))}
</ul>
) : (
<div
ref={stageRef}
role="group"
aria-label={`${label}: use the Left and Right arrow keys to rotate`}
tabIndex={0}
onKeyDown={onKeyDown}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
onClickCapture={(e) => {
// A drag that ends over a card must not also click it.
if (suppressClick.current) {
e.preventDefault();
e.stopPropagation();
suppressClick.current = false;
}
}}
className={`relative grid w-full touch-pan-y select-none place-items-center overflow-hidden rounded-2xl outline-none focus-visible:shadow-[inset_0_0_0_2px_var(--rc-accent)] ${
dragging ? "cursor-grabbing" : "cursor-grab"
}`}
style={{ height: `calc(${cardHeight + verticalRoom}px * var(--rc-scale, 1) + 1rem)` }}
>
{/* Floor shadow under the ring. */}
<div
aria-hidden="true"
className="pointer-events-none absolute left-1/2 top-1/2 h-10 -translate-x-1/2 rounded-[50%] bg-black/[0.12] blur-2xl dark:bg-black/50"
style={{ width: `calc(${radius * 1.6 + cardWidth}px * var(--rc-scale, 1))`, marginTop: `calc(${cardHeight / 2}px * var(--rc-scale, 1))` }}
/>
<div className="relative [transform-style:preserve-3d]" style={{ width: cardWidth, height: cardHeight, perspective, scale: "var(--rc-scale, 1)" }}>
<motion.div
className="absolute inset-0 [transform-style:preserve-3d]"
style={{ z: -radius, rotateX: tilt, rotateY: angle }}
>
{items.map((item, i) => {
const isFront = i === front;
const rel = ((i * step - startIndex * step) * Math.PI) / 180;
return (
<div
key={item.id}
ref={(el) => {
cardRefs.current[i] = el;
}}
aria-hidden={!isFront}
onClick={() => !isFront && goTo(i)}
className="absolute inset-0 [transform-style:preserve-3d]"
style={{
transform: `rotateY(${i * step}deg) translateZ(${radius}px)`,
["--rc-dim" as string]: ((1 - Math.cos(rel)) * 0.3).toFixed(3),
}}
>
<div
role="group"
aria-roledescription="slide"
aria-label={`${i + 1} of ${n}: ${item.label}`}
inert={!isFront}
className={`absolute inset-0 overflow-hidden rounded-2xl border bg-white transition-[border-color,box-shadow] duration-300 [backface-visibility:hidden] dark:bg-zinc-900 ${
isFront
? "border-[var(--rc-accent)] shadow-[0_24px_60px_-28px_rgba(0,0,0,0.45),0_0_0_3px_color-mix(in_srgb,var(--rc-accent)_22%,transparent)]"
: "cursor-pointer border-zinc-900/10 dark:border-white/10"
}`}
>
{item.content}
<div aria-hidden="true" className="pointer-events-none absolute inset-0 bg-zinc-950" style={{ opacity: "var(--rc-dim)" }} />
</div>
{/* Back face: a plain panel instead of mirrored content. */}
<div
aria-hidden="true"
className="absolute inset-0 rounded-2xl border border-zinc-900/10 bg-zinc-200 [backface-visibility:hidden] [transform:rotateY(180deg)] dark:border-white/10 dark:bg-zinc-800"
/>
</div>
);
})}
</motion.div>
</div>
</div>
)}
{showControls && (
<div className="mt-4 flex items-center justify-center gap-2">
<button type="button" aria-label="Previous item" onClick={() => (flat ? scrollFlat(-1) : stepBy(-1))} className={iconButton}>
<Chevron dir="left" />
</button>
{autoRotate && !flat && (
<button
type="button"
aria-label={userPaused ? "Start automatic rotation" : "Stop automatic rotation"}
onClick={() => setUserPaused((p) => !p)}
className={iconButton}
>
<svg aria-hidden="true" viewBox="0 0 16 16" className="size-3.5" fill="currentColor">
{userPaused ? <path d="M5 3.2v9.6c0 .5.5.8.9.5l7.2-4.8c.4-.3.4-.8 0-1L5.9 2.7c-.4-.3-.9 0-.9.5Z" /> : <path d="M4.5 3h2.2v10H4.5zM9.3 3h2.2v10H9.3z" />}
</svg>
</button>
)}
{!flat && (
<span aria-hidden="true" className="min-w-[9rem] text-center font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400">
<span className="text-zinc-900 dark:text-zinc-100">{String(front + 1).padStart(2, "0")}</span> / {String(n).padStart(2, "0")} ·{" "}
{current.label}
</span>
)}
<button type="button" aria-label="Next item" onClick={() => (flat ? scrollFlat(1) : stepBy(1))} className={iconButton}>
<Chevron dir="right" />
</button>
</div>
)}
{/* Announces the front card; silent while rotating automatically. */}
{!flat && (
<div className="sr-only" aria-live={running ? "off" : "polite"} aria-atomic="true">
{`Item ${front + 1} of ${n}: ${current.label}`}
</div>
)}
</section>
);
}