A horizontal 3D coverflow — the center cover faces flat while neighbors rotate away in perspective; drag or arrow to move, with optional reflection.
npx shadcn@latest add @paragon/coverflow"use client";
import * as React from "react";
import {
animate,
motion,
useMotionValue,
useReducedMotion,
useTransform,
} from "motion/react";
import { cn } from "@/lib/utils";
/**
* Coverflow — a horizontal 3D coverflow. The centered item faces the viewer
* flat; items to either side rotate away on Y into perspective and slide behind
* on Z, depth-sorted so the active cover always sits on top. Drag horizontally
* or use the arrow keys to move the selection; an optional mirrored reflection
* grounds each cover. A continuous `position` motion value drives the layout so
* dragging and snapping are one smooth spring.
*
* Pass `items`; otherwise deterministic gradient covers render. Reduced motion
* removes the spring (instant snaps) but keeps the arrangement.
*/
export interface CoverflowProps
extends Omit<React.ComponentProps<"div">, "children" | "onChange"> {
items?: React.ReactNode[];
/** Number of generated covers when `items` is omitted. */
count?: number;
/** Horizontal spacing between neighboring covers, px. */
spacing?: number;
/** Peak rotation of side covers, degrees. */
rotation?: number;
/** Perspective depth, px. */
perspective?: number;
/** Cover edge size, px. */
size?: number;
/** Show the mirrored reflection under each cover. */
reflection?: boolean;
/** Controlled/initial active index. */
defaultIndex?: number;
onIndexChange?: (index: number) => void;
}
const COVER_GRADIENTS = [
"linear-gradient(145deg, oklch(0.72 0.16 250), oklch(0.5 0.2 285))",
"linear-gradient(145deg, oklch(0.78 0.15 165), oklch(0.55 0.16 200))",
"linear-gradient(145deg, oklch(0.82 0.16 70), oklch(0.64 0.19 38))",
"linear-gradient(145deg, oklch(0.75 0.17 18), oklch(0.55 0.18 348))",
"linear-gradient(145deg, oklch(0.76 0.13 300), oklch(0.52 0.16 262))",
"linear-gradient(145deg, oklch(0.8 0.14 200), oklch(0.6 0.14 236))",
"linear-gradient(145deg, oklch(0.8 0.15 128), oklch(0.6 0.17 160))",
"linear-gradient(145deg, oklch(0.84 0.16 92), oklch(0.66 0.18 52))",
];
function GeneratedCover({ i, size }: { i: number; size: number }) {
return (
<div
className="flex size-full flex-col justify-end rounded-2xl p-4"
style={{ background: COVER_GRADIENTS[i % COVER_GRADIENTS.length] }}
>
<span className="text-xs font-medium uppercase tracking-wide text-white/75">
Track
</span>
<span className="text-2xl font-semibold tabular-nums text-white drop-shadow-[0_1px_4px_rgba(0,0,0,0.4)]">
{String(i + 1).padStart(2, "0")}
</span>
</div>
);
}
export function Coverflow({
items,
count = 7,
spacing = 78,
rotation = 55,
perspective = 1100,
size = 172,
reflection = true,
defaultIndex,
onIndexChange,
className,
style,
...props
}: CoverflowProps) {
const reduced = useReducedMotion() ?? false;
const covers = React.useMemo<React.ReactNode[]>(() => {
if (items && items.length) return items;
return Array.from({ length: count }, (_, i) => (
<GeneratedCover key={i} i={i} size={size} />
));
}, [items, count, size]);
const n = covers.length;
const clamp = (v: number) => Math.max(0, Math.min(n - 1, v));
const start = clamp(defaultIndex ?? Math.floor(n / 2));
const [index, setIndex] = React.useState(start);
// Continuous position (fractional index) for buttery dragging.
const position = useMotionValue(start);
const dragStart = React.useRef(0);
const goTo = React.useCallback(
(raw: number) => {
const next = clamp(Math.round(raw));
setIndex(next);
onIndexChange?.(next);
if (reduced) {
position.set(next);
} else {
animate(position, next, {
type: "spring",
stiffness: 260,
damping: 30,
});
}
},
[clamp, onIndexChange, position, reduced],
);
return (
<div
role="listbox"
aria-label="Coverflow"
aria-orientation="horizontal"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "ArrowLeft") {
e.preventDefault();
goTo(index - 1);
} else if (e.key === "ArrowRight") {
e.preventDefault();
goTo(index + 1);
}
}}
className={cn(
"relative flex w-full touch-pan-y select-none items-center justify-center outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
className,
)}
style={{
perspective: `${perspective}px`,
height: size * 1.85,
...style,
}}
{...props}
>
{/* Drag surface (captures pointer). */}
<motion.div
className="absolute inset-0 z-40 cursor-grab active:cursor-grabbing"
drag="x"
dragConstraints={{ left: 0, right: 0 }}
dragElastic={0}
dragMomentum={false}
onDragStart={() => {
dragStart.current = position.get();
}}
onDrag={(_, info) => {
position.set(clamp(dragStart.current - info.offset.x / spacing));
}}
onDragEnd={() => goTo(position.get())}
/>
<div
className="relative"
style={{
transformStyle: "preserve-3d",
width: size,
height: size,
}}
>
{covers.map((cover, i) => (
<CoverItem
key={i}
index={i}
active={i === index}
position={position}
spacing={spacing}
rotation={rotation}
size={size}
reflection={reflection}
count={n}
onClick={() => goTo(i)}
>
{cover}
</CoverItem>
))}
</div>
</div>
);
}
function CoverItem({
index,
active,
position,
spacing,
rotation,
size,
reflection,
count,
onClick,
children,
}: {
index: number;
active: boolean;
position: ReturnType<typeof useMotionValue<number>>;
spacing: number;
rotation: number;
size: number;
reflection: boolean;
count: number;
onClick: () => void;
children: React.ReactNode;
}) {
// Signed distance from the active (fractional) position.
const offset = useTransform(position, (p) => index - p);
const x = useTransform(offset, (o) => {
const dir = Math.sign(o);
// Compress spacing near the center so the active cover has room.
return dir * (Math.min(Math.abs(o), 1) * spacing * 1.1 + Math.max(Math.abs(o) - 1, 0) * spacing);
});
const rotateY = useTransform(offset, (o) => {
const clamped = Math.max(-1, Math.min(1, o));
return -clamped * rotation;
});
const z = useTransform(offset, (o) => -Math.abs(o) * 120);
const scale = useTransform(offset, (o) => 1 - Math.min(Math.abs(o), 3) * 0.06);
const opacity = useTransform(offset, (o) =>
Math.abs(o) > count ? 0 : Math.max(0.35, 1 - Math.abs(o) * 0.18),
);
const zIndex = useTransform(offset, (o) => Math.round(100 - Math.abs(o) * 10));
return (
<motion.button
type="button"
aria-label={`Cover ${index + 1}`}
aria-selected={active}
role="option"
onClick={onClick}
className="absolute left-0 top-0 will-change-transform"
style={{
width: size,
height: size,
x,
z,
rotateY,
scale,
opacity,
zIndex,
transformStyle: "preserve-3d",
}}
>
<div className="size-full overflow-hidden rounded-2xl shadow-overlay [backface-visibility:hidden]">
{children}
</div>
{reflection && (
<div
aria-hidden
className="absolute left-0 top-full w-full overflow-hidden rounded-2xl [transform:scaleY(-1)] [mask-image:linear-gradient(to_bottom,rgba(0,0,0,0.4),transparent_62%)]"
style={{ height: size * 0.7, opacity: 0.5 }}
>
{children}
</div>
)}
</motion.button>
);
}