"use client";
import * as React from "react";
import { animate, motion, useMotionValue, useTransform, type AnimationPlaybackControls, type MotionValue } 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 CoverflowItem {
id: string;
content: React.ReactNode;
/** Short title, used in announcements ("Album 3 of 9: title") and slide names. */
label: string;
}
export interface CoverflowSpring {
stiffness: number;
damping: number;
mass: number;
}
export interface CoverflowProps extends Omit<React.ComponentPropsWithoutRef<"section">, "children"> {
items: CoverflowItem[];
/** Accessible name of the carousel. */
label?: string;
/** Noun used in announcements, e.g. "Album" → "Album 3 of 9: title". */
itemName?: string;
/** Cover size (width and height) in px. */
size?: number;
/** Rotation of the side covers, in degrees. */
angle?: number;
/** Distance between stacked side covers, as a fraction of the cover size. */
spacing?: number;
/** Spring used when the row moves and snaps. */
spring?: CoverflowSpring;
/** A faded mirror image under each cover. */
reflection?: boolean;
/** Previous / next buttons and the counter. */
showControls?: boolean;
/** Focus ring, centre-cover edge and control colour (any CSS colour). */
accent?: string;
/** Initially centred item. */
defaultIndex?: number;
/** Called when the centred item changes. */
onIndexChange?: (index: number) => void;
}
const VISIBLE_SIDE = 4; // covers shown on each side before they fade out
const r2 = (n: number) => Math.round(n * 100) / 100;
const clampIndex = (i: number, n: number) => Math.max(0, Math.min(n - 1, i));
const iconButton =
"grid size-9 place-items-center rounded-full border border-zinc-900/12 bg-white text-zinc-700 outline-hidden transition-colors hover:border-zinc-900/25 hover:text-zinc-950 aria-disabled:opacity-35 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--cf-accent)] 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>
);
}
interface Geometry {
size: number;
angle: number;
gap: number;
step: number;
depth: number;
}
/**
* One cover. Its transform is derived from the shared position motion value, so the whole row
* moves on one spring without re-rendering React. Side covers are aria-hidden with inert content;
* a click on one (hit-testing falls through the inert content to this wrapper) centres it.
*/
function CoverflowCard({
item,
index,
count,
pos,
geo,
centred,
reflection,
onSelect,
}: {
item: CoverflowItem;
index: number;
count: number;
pos: MotionValue<number>;
geo: Geometry;
centred: boolean;
reflection: boolean;
onSelect: (index: number) => void;
}) {
const offset = useTransform(pos, (p) => index - p);
const x = useTransform(offset, (o) => {
const a = Math.abs(o);
return r2(Math.sign(o) * (Math.min(1, a) * geo.gap + Math.max(0, a - 1) * geo.step));
});
const z = useTransform(offset, (o) => r2(-Math.min(1, Math.abs(o)) * geo.depth));
const rotateY = useTransform(offset, (o) => r2(-Math.sign(o) * Math.min(1, Math.abs(o)) * geo.angle));
const zIndex = useTransform(offset, (o) => 100 - Math.round(Math.abs(o) * 10));
const opacity = useTransform(offset, (o) => r2(Math.max(0, Math.min(1, VISIBLE_SIDE + 1 - Math.abs(o)))));
const shade = useTransform(offset, (o) => r2(Math.min(1, Math.abs(o)) * 0.42));
return (
<motion.div
role="group"
aria-roledescription="slide"
aria-label={`${index + 1} of ${count}: ${item.label}`}
aria-hidden={!centred}
onClick={centred ? undefined : () => onSelect(index)}
className={`absolute left-1/2 top-0 ${centred ? "" : "cursor-pointer"}`}
style={{ width: geo.size, height: geo.size, marginLeft: -geo.size / 2, x, z, rotateY, zIndex, opacity }}
>
<div
inert={!centred}
className={`relative h-full w-full overflow-hidden rounded-[4px] border bg-white shadow-[0_18px_40px_-22px_rgb(0_0_0/0.55)] dark:bg-zinc-900 ${
centred ? "border-[color-mix(in_srgb,var(--cf-accent)_55%,transparent)]" : "border-zinc-900/10 dark:border-white/10"
}`}
>
{item.content}
<motion.div aria-hidden="true" className="pointer-events-none absolute inset-0 bg-zinc-950" style={{ opacity: shade }} />
</div>
{reflection && (
<div
aria-hidden="true"
inert
className="pointer-events-none absolute inset-x-0 top-full mt-1.5 h-full -scale-y-100 overflow-hidden rounded-[4px] opacity-35 [mask-image:linear-gradient(to_top,#000,transparent_42%)] dark:opacity-30"
>
{item.content}
<motion.div className="absolute inset-0 bg-zinc-950" style={{ opacity: shade }} />
</div>
)}
</motion.div>
);
}
/**
* The classic coverflow: the centred cover faces forward, the others turn away on rotateY and
* stack in depth. Navigate with drag or swipe (the flick carries into a spring), a horizontal
* wheel or trackpad swipe, the arrow keys while the stage is focused, the buttons, or by clicking
* a side cover. Only the centred cover is interactive; the rest are inert and hidden from
* assistive tech, and the centred one is announced as "Album 3 of 9: title".
*/
export function Coverflow({
items,
label = "Coverflow",
itemName = "Item",
size = 220,
angle = 60,
spacing = 0.26,
spring = { stiffness: 220, damping: 30, mass: 1 },
reflection = true,
showControls = true,
accent = "#FF7448",
defaultIndex = 0,
onIndexChange,
className = "",
style,
...props
}: CoverflowProps) {
const reduceMotion = usePrefersReducedMotion();
const n = items.length;
const start = n ? clampIndex(Math.round(defaultIndex), n) : 0;
const { stiffness, damping, mass } = spring;
const rootRef = React.useRef<HTMLElement>(null);
const stageRef = React.useRef<HTMLDivElement>(null);
const pos = useMotionValue(start);
const anim = React.useRef<AnimationPlaybackControls | null>(null);
const target = React.useRef(start);
const indexRef = React.useRef(start);
const scale = React.useRef(1);
const onIndexChangeRef = React.useRef(onIndexChange);
const drag = React.useRef<{ id: number; startX: number; start: number; x: number; t: number; v: number; moved: boolean } | null>(null);
const suppressClick = React.useRef(false);
const [index, setIndex] = React.useState(start);
const [dragging, setDragging] = React.useState(false);
const geo: Geometry = {
size,
angle: Math.max(0, Math.min(89, angle)),
gap: size * 0.7,
step: size * Math.max(0.05, spacing),
depth: size * 0.6,
};
React.useEffect(() => {
onIndexChangeRef.current = onIndexChange;
});
// The centred index follows the position (also mid-drag), without per-frame React renders.
React.useEffect(() => {
if (!n) return;
return pos.on("change", (p) => {
const i = clampIndex(Math.round(p), n);
if (i !== indexRef.current) {
indexRef.current = i;
setIndex(i);
onIndexChangeRef.current?.(i);
}
});
}, [pos, n]);
// Scale the scene down to fit narrow containers.
React.useEffect(() => {
const stage = stageRef.current;
const root = rootRef.current;
if (!stage || !root) return;
const ro = new ResizeObserver(() => {
const s = Math.min(1, stage.clientWidth / (size * 3.1));
scale.current = s;
root.style.setProperty("--cf-scale", s.toFixed(3));
});
ro.observe(stage);
return () => ro.disconnect();
}, [size]);
React.useEffect(() => () => anim.current?.stop(), []);
const goTo = (to: number, velocity = 0) => {
if (!n) return;
const next = clampIndex(to, n);
target.current = next;
anim.current?.stop();
if (reduceMotion) pos.set(next);
else anim.current = animate(pos, next, { type: "spring", stiffness, damping, mass, velocity });
};
/** Steps from the current target, so quick repeated presses accumulate. */
const stepBy = (delta: number) => goTo(target.current + delta);
// The wheel listener is attached once; it calls the latest goTo through a ref.
const goToRef = React.useRef(goTo);
React.useEffect(() => {
goToRef.current = goTo;
});
// Wheel: horizontal deltas (trackpad swipes, Shift + wheel) step through the covers. Vertical
// wheel scrolling is left to the page.
React.useEffect(() => {
const stage = stageRef.current;
if (!stage || n < 2) return;
let acc = 0;
let idle = 0;
const onWheel = (e: WheelEvent) => {
const dx = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.shiftKey ? e.deltaY : 0;
if (!dx) return;
e.preventDefault(); // also stops the browser's swipe-to-go-back gesture
acc += e.deltaMode === 1 ? dx * 16 : dx;
while (Math.abs(acc) >= 60) {
const dir = Math.sign(acc);
acc -= dir * 60;
goToRef.current(target.current + dir);
}
clearTimeout(idle);
idle = window.setTimeout(() => (acc = 0), 180);
};
stage.addEventListener("wheel", onWheel, { passive: false });
return () => {
stage.removeEventListener("wheel", onWheel);
clearTimeout(idle);
};
}, [n]);
/* --------------------------------- Drag --------------------------------- */
const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
if (e.button !== 0 || n < 2) return;
suppressClick.current = false;
drag.current = { id: e.pointerId, startX: e.clientX, start: pos.get(), x: e.clientX, t: e.timeStamp, 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;
// Capture only once it is a drag, so plain clicks still reach the covers.
d.moved = true;
e.currentTarget.setPointerCapture(e.pointerId);
anim.current?.stop();
d.start = pos.get();
d.startX = e.clientX;
setDragging(true);
}
const perItem = geo.size * 0.45 * scale.current;
const dt = Math.max(1, e.timeStamp - d.t) / 1000;
d.v = d.v * 0.6 + ((-(e.clientX - d.x) / perItem) / dt) * 0.4;
d.x = e.clientX;
d.t = e.timeStamp;
// Rubber-band past either end.
let p = d.start - (e.clientX - d.startX) / perItem;
if (p < 0) p *= 0.35;
if (p > n - 1) p = n - 1 + (p - (n - 1)) * 0.35;
pos.set(p);
};
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 projected = pos.get() + Math.max(-3, Math.min(3, velocity * 0.18));
goTo(Math.round(projected), velocity);
};
const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.target !== e.currentTarget) return;
const keys: Record<string, () => void> = {
ArrowLeft: () => stepBy(-1),
ArrowRight: () => stepBy(1),
Home: () => goTo(0),
End: () => goTo(n - 1),
};
const action = keys[e.key];
if (!action) return;
e.preventDefault();
action();
};
if (!n) return null;
const current = items[index];
const stageHeight = Math.round(size * (reflection ? 1.5 : 1.12) + 24);
return (
<section
ref={rootRef}
aria-roledescription="carousel"
aria-label={label}
className={`relative w-full ${className}`}
style={{ ["--cf-accent" as string]: accent, ...style }}
{...props}
>
<div
ref={stageRef}
role="group"
aria-label={`${label}: use the Left and Right arrow keys to browse`}
tabIndex={0}
onKeyDown={onKeyDown}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
onClickCapture={(e) => {
// A drag that ends over a cover must not also click it.
if (suppressClick.current) {
e.preventDefault();
e.stopPropagation();
suppressClick.current = false;
}
}}
className={`relative w-full touch-pan-y select-none overflow-hidden rounded-2xl outline-hidden focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--cf-accent)] ${
dragging ? "cursor-grabbing" : n > 1 ? "cursor-grab" : ""
}`}
style={{ height: `calc(${stageHeight}px * var(--cf-scale, 1))` }}
>
{/* Covers are direct children of the perspective box and are painted in z-index order. */}
<div
className="absolute left-1/2 top-3 origin-top"
style={{ width: 0, height: size, perspective: size * 4.2, scale: "var(--cf-scale, 1)" }}
>
{items.map((item, i) => (
<CoverflowCard
key={item.id}
item={item}
index={i}
count={n}
pos={pos}
geo={geo}
centred={i === index}
reflection={reflection}
onSelect={goTo}
/>
))}
</div>
</div>
{showControls && (
<div className="mt-3 flex items-center justify-center gap-2">
<button type="button" aria-label={`Previous ${itemName.toLowerCase()}`} onClick={() => stepBy(-1)} aria-disabled={index === 0} className={iconButton}>
<Chevron dir="left" />
</button>
<span aria-hidden="true" className="min-w-[10rem] truncate 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(index + 1).padStart(2, "0")}</span> / {String(n).padStart(2, "0")} · {current.label}
</span>
<button type="button" aria-label={`Next ${itemName.toLowerCase()}`} onClick={() => stepBy(1)} aria-disabled={index === n - 1} className={iconButton}>
<Chevron dir="right" />
</button>
</div>
)}
<div className="sr-only" aria-live="polite" aria-atomic="true">
{`${itemName} ${index + 1} of ${n}: ${current.label}`}
</div>
</section>
);
}