Overview

A record-crate carousel: the centred cover faces you while its neighbours turn away and stack tighter behind it, shading and softly blurring as they recede, with a mirrored reflection on the floor. One spring-driven position drives every card, so drags track the finger exactly, flicks carry across several covers before settling, and trackpad swipes scrub the deck. The caption slides and un-blurs in the direction of travel, and a dot strip shows where you are.


Installation

Use the CLI to install the component automatically:

$ pnpm dlx shadcn@latest add https://ui.sanjid.in/r/coverflow.json

Install dependencies:

$ pnpm add motion

Add the utility function for class merging:

lib/utils.ts
tsx
import { ClassValue, clsx } from "clsx";import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {  return twMerge(clsx(inputs));}

Copy the component code into your project:

components/ui/coverflow.tsx
tsx
"use client";
import React, { useCallback, useEffect, useId, useRef, useState } from "react";import {  AnimatePresence,  animate,  motion,  useMotionValue,  useMotionValueEvent,  useReducedMotion,  useTransform,  type AnimationPlaybackControls,  type MotionValue,} from "motion/react";import { ChevronLeft, ChevronRight } from "lucide-react";import { cn } from "@/lib/utils";
export type CoverflowItem = {  id: string;  title: string;  subtitle?: string;  image?: string;  /** CSS background used when there is no image. */  surface?: string;};
export type CoverflowProps = {  items: CoverflowItem[];  className?: string;  /** Distance between the active card and its first neighbour, in px. Defaults to ~62% of the card width. */  spacing?: number;  initialIndex?: number;  onIndexChange?: (index: number) => void;  /** Called when the already-centred card is clicked or Enter is pressed. */  onSelect?: (item: CoverflowItem, index: number) => void;  /** Mirror the cards on the floor below. */  reflection?: boolean;};
const SETTLE = { type: "spring" as const, stiffness: 240, damping: 30, mass: 0.9 };const EASE_OUT = [0.23, 1, 0.32, 1] as const;
const clamp = (v: number, min: number, max: number) => Math.min(max, Math.max(min, v));/** Soft limit past the ends: the further you pull, the less it gives. */const rubber = (over: number) => (0.4 * over) / (1 + over);
type Geometry = { cardW: number; cardH: number; spacing: number; tilt: number };
function CoverCard({  item,  index,  position,  geo,  reduce,  reflection,  selected,  optionId,}: {  item: CoverflowItem;  index: number;  position: MotionValue<number>;  geo: Geometry;  reduce: boolean;  reflection: boolean;  selected: boolean;  optionId: string;}) {  const { cardW, cardH, spacing, tilt } = geo;  const offset = useTransform(position, (p) => index - p);
  // First neighbour sits a full `spacing` away; the rest stack tighter  // behind it, like records in a crate.  const x = useTransform(offset, (o) => {    const a = Math.abs(o);    const d = a <= 1 ? a * spacing : spacing + (a - 1) * spacing * 0.42;    return Math.sign(o) * d;  });  const rotateY = useTransform(offset, (o) => (reduce ? 0 : -clamp(o, -1, 1) * tilt));  const z = useTransform(offset, (o) => {    if (reduce) return 0;    const a = Math.abs(o);    return -Math.min(a, 1) * cardW * 0.42 - Math.max(0, a - 1) * 24;  });  const scale = useTransform(offset, (o) => (reduce ? 1 - Math.min(Math.abs(o), 1) * 0.12 : 1));  const opacity = useTransform(offset, (o) => clamp(3.4 - Math.abs(o), 0, 1));  const filter = useTransform(offset, (o) =>    reduce ? "none" : `blur(${clamp((Math.abs(o) - 0.8) * 1.4, 0, 3.5).toFixed(2)}px)`,  );  const zIndex = useTransform(offset, (o) => 1000 - Math.round(Math.abs(o) * 10));  // Turned-away edges fall into shadow; the near edge catches light.  const shadeRight = useTransform(offset, (o) => clamp(o, 0, 1) * 0.55);  const shadeLeft = useTransform(offset, (o) => clamp(-o, 0, 1) * 0.55);  const [loaded, setLoaded] = useState(false);
  const face = (    <div className="absolute inset-0 overflow-hidden rounded-[14px] bg-(--color-paper-2)">      {item.image ? (        // eslint-disable-next-line @next/next/no-img-element        <img          src={item.image}          alt=""          draggable={false}          decoding="async"          onLoad={() => setLoaded(true)}          ref={(el) => {            if (el?.complete && el.naturalWidth) setLoaded(true);          }}          className={cn(            "size-full object-cover object-top transition-[opacity,filter] duration-500",            loaded ? "opacity-100 blur-0" : "opacity-0 blur-md",          )}        />      ) : (        <div          className="size-full"          style={{            background:              item.surface ?? "linear-gradient(145deg, var(--color-paper-2), var(--color-rule))",          }}        />      )}    </div>  );
  return (    <motion.div      id={optionId}      role="option"      aria-selected={selected}      aria-label={`${item.title}${item.subtitle ? `, ${item.subtitle}` : ""}`}      data-coverflow-index={index}      className="absolute top-0 left-1/2 cursor-pointer"      style={{        width: cardW,        height: cardH,        marginLeft: -cardW / 2,        x,        z,        rotateY,        scale,        opacity,        filter,        zIndex,      }}    >      <div        className={cn(          "relative size-full rounded-[14px]",          "shadow-[0_1px_2px_rgba(0,0,0,0.06),0_24px_48px_-16px_rgba(0,0,0,0.35)]",          "dark:shadow-[0_24px_56px_-16px_rgba(0,0,0,0.8)]",        )}      >        {face}        {/* Gloss + hairline */}        <div          aria-hidden          className="pointer-events-none absolute inset-0 rounded-[14px] bg-[linear-gradient(160deg,rgba(255,255,255,0.22),rgba(255,255,255,0)_38%)] shadow-[inset_0_0_0_1px_rgba(0,0,0,0.08)] dark:shadow-[inset_0_0_0_1px_rgba(255,255,255,0.1)]"        />        <motion.div          aria-hidden          style={{ opacity: shadeRight }}          className="pointer-events-none absolute inset-0 rounded-[14px] bg-[linear-gradient(90deg,rgba(0,0,0,0.05),rgba(0,0,0,0.85))]"        />        <motion.div          aria-hidden          style={{ opacity: shadeLeft }}          className="pointer-events-none absolute inset-0 rounded-[14px] bg-[linear-gradient(270deg,rgba(0,0,0,0.05),rgba(0,0,0,0.85))]"        />      </div>
      {reflection ? (        <div          aria-hidden          className="pointer-events-none absolute inset-x-0 top-full mt-1.5 -scale-y-100 opacity-20 dark:opacity-15"          style={{            height: cardH,            maskImage: "linear-gradient(to top, rgba(0,0,0,0.9), transparent 42%)",            WebkitMaskImage: "linear-gradient(to top, rgba(0,0,0,0.9), transparent 42%)",          }}        >          {face}        </div>      ) : null}    </motion.div>  );}
export function Coverflow({  items,  className,  spacing: spacingProp,  initialIndex = 0,  onIndexChange,  onSelect,  reflection = true,}: CoverflowProps) {  const reduce = !!useReducedMotion();  const uid = useId();  const last = Math.max(items.length - 1, 0);  const start = clamp(initialIndex, 0, last);
  const stageRef = useRef<HTMLDivElement>(null);  const [width, setWidth] = useState(720);  const position = useMotionValue(start);  const [index, setIndex] = useState(start);  const [direction, setDirection] = useState(1);  const anim = useRef<AnimationPlaybackControls | null>(null);  const target = useRef(start);  const drag = useRef<{    x: number;    from: number;    pointerId: number;    moved: boolean;    hit: number | null;  } | null>(null);  const [grabbing, setGrabbing] = useState(false);
  useEffect(() => {    const el = stageRef.current;    if (!el) return;    const ro = new ResizeObserver(([entry]) => setWidth(entry.contentRect.width));    ro.observe(el);    return () => ro.disconnect();  }, []);
  const cardW = Math.round(clamp(width * 0.5, 200, 400));  const cardH = Math.round(cardW * 0.75);  const geo: Geometry = {    cardW,    cardH,    spacing: spacingProp ?? Math.round(cardW * 0.6),    tilt: width < 520 ? 44 : 52,  };
  const indexRef = useRef(start);  useMotionValueEvent(position, "change", (v) => {    const next = clamp(Math.round(v), 0, last);    if (next === indexRef.current) return;    setDirection(next > indexRef.current ? 1 : -1);    indexRef.current = next;    setIndex(next);  });
  const goTo = useCallback(    (next: number, velocity = 0) => {      const to = clamp(Math.round(next), 0, last);      anim.current?.stop();      anim.current = animate(        position,        to,        reduce ? { duration: 0.2, ease: EASE_OUT } : { ...SETTLE, velocity },      );      if (to !== target.current) {        target.current = to;        onIndexChange?.(to);      }    },    [last, onIndexChange, position, reduce],  );
  /* -------------------------------- pointer -------------------------------- */
  const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {    if (!e.isPrimary || e.button !== 0) return;    anim.current?.stop();    const hit = (e.target as Element).closest<HTMLElement>("[data-coverflow-index]");    drag.current = {      x: e.clientX,      from: position.get(),      pointerId: e.pointerId,      moved: false,      hit: hit ? Number(hit.dataset.coverflowIndex) : null,    };  };
  const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {    const d = drag.current;    if (!d || d.pointerId !== e.pointerId) return;    const dx = e.clientX - d.x;    if (!d.moved) {      if (Math.abs(dx) < 5) return;      d.moved = true;      setGrabbing(true);      try {        e.currentTarget.setPointerCapture(e.pointerId);      } catch {        /* Pointer already gone; window-level moves still arrive. */      }    }    let p = d.from - dx / geo.spacing;    if (p < 0) p = -rubber(-p);    else if (p > last) p = last + rubber(p - last);    position.set(p);  };
  const onPointerUp = (e: React.PointerEvent<HTMLDivElement>) => {    const d = drag.current;    drag.current = null;    if (!d || d.pointerId !== e.pointerId) return;    setGrabbing(false);    if (!d.moved) {      // A tap: centre a side card, or select the one already centred.      if (d.hit === null) return goTo(target.current);      if (d.hit === target.current) {        goTo(d.hit);        onSelect?.(items[d.hit], d.hit);      } else goTo(d.hit);      return;    }    // Project the flick forward, then settle on the nearest card.    const v = position.getVelocity();    goTo(position.get() + v * 0.22, v);  };
  // Trackpad horizontal scroll scrubs the deck, then snaps.  useEffect(() => {    const el = stageRef.current;    if (!el) return;    let timer = 0;    const onWheel = (e: WheelEvent) => {      if (Math.abs(e.deltaX) <= Math.abs(e.deltaY) && !e.shiftKey) return;      e.preventDefault();      anim.current?.stop();      const delta = (e.shiftKey && !e.deltaX ? e.deltaY : e.deltaX) / (geo.spacing * 1.1);      position.set(clamp(position.get() + delta, -0.3, last + 0.3));      window.clearTimeout(timer);      timer = window.setTimeout(() => goTo(position.get()), 120);    };    el.addEventListener("wheel", onWheel, { passive: false });    return () => {      el.removeEventListener("wheel", onWheel);      window.clearTimeout(timer);    };  }, [geo.spacing, goTo, last, position]);
  const onKeyDown = (e: React.KeyboardEvent) => {    const keys: Record<string, number> = {      ArrowLeft: target.current - 1,      ArrowRight: target.current + 1,      Home: 0,      End: last,    };    if (e.key in keys) {      e.preventDefault();      goTo(keys[e.key]);    } else if ((e.key === "Enter" || e.key === " ") && onSelect) {      e.preventDefault();      onSelect(items[target.current], target.current);    }  };
  if (!items.length) return null;  const active = items[index];  const stageH = cardH + (reflection ? Math.round(cardH * 0.4) : 24);
  return (    <div className={cn("flex w-full flex-col items-center gap-5", className)}>      <div        ref={stageRef}        role="listbox"        aria-label="Coverflow"        aria-orientation="horizontal"        aria-activedescendant={`${uid}-opt-${index}`}        tabIndex={0}        onKeyDown={onKeyDown}        onPointerDown={onPointerDown}        onPointerMove={onPointerMove}        onPointerUp={onPointerUp}        onPointerCancel={onPointerUp}        className={cn(          "relative w-full touch-pan-y overflow-hidden rounded-[18px] outline-none select-none",          "focus-visible:ring-2 focus-visible:ring-(--color-focus) focus-visible:ring-offset-4 focus-visible:ring-offset-(--color-paper)",          grabbing ? "cursor-grabbing" : "cursor-grab",        )}        style={{          height: stageH + 32,          paddingTop: 16,          // Fade the far cards into the page at both sides.          maskImage: "linear-gradient(90deg, transparent, #000 12%, #000 88%, transparent)",          WebkitMaskImage: "linear-gradient(90deg, transparent, #000 12%, #000 88%, transparent)",        }}      >        <div          className="relative h-full"          style={{ perspective: cardW * 3.6, perspectiveOrigin: "50% 40%" }}        >          {items.map((item, i) => (            <CoverCard              key={item.id}              item={item}              index={i}              position={position}              geo={geo}              reduce={reduce}              reflection={reflection}              selected={i === index}              optionId={`${uid}-opt-${i}`}            />          ))}        </div>      </div>
      {/* Caption */}      <div className="relative grid h-12 w-full place-items-center overflow-hidden text-center" aria-live="polite">        <AnimatePresence initial={false} mode="popLayout" custom={direction}>          <motion.div            key={active.id}            custom={direction}            variants={{              enter: (dir: number) =>                reduce ? { opacity: 0 } : { opacity: 0, x: dir * 24, filter: "blur(6px)" },              center: { opacity: 1, x: 0, filter: "blur(0px)" },              exit: (dir: number) =>                reduce ? { opacity: 0 } : { opacity: 0, x: dir * -24, filter: "blur(6px)" },            }}            initial="enter"            animate="center"            exit="exit"            transition={{ duration: 0.32, ease: EASE_OUT }}            className="col-start-1 row-start-1"          >            <div className="font-(family-name:--font-display) text-[17px] font-semibold tracking-[-0.015em] text-(--color-ink)">              {active.title}            </div>            {active.subtitle ? (              <div className="mt-0.5 text-[13px] text-(--color-neutral)">{active.subtitle}</div>            ) : null}          </motion.div>        </AnimatePresence>      </div>
      {/* Controls */}      <div className="flex items-center gap-3">        <NavButton label="Previous" disabled={index === 0} onClick={() => goTo(target.current - 1)}>          <ChevronLeft />        </NavButton>        <div className="flex items-center gap-1 rounded-full px-1.5 py-1">          {items.map((item, i) => (            <button              key={item.id}              type="button"              aria-label={`Show ${item.title}`}              aria-current={i === index ? "true" : undefined}              onClick={() => goTo(i)}              className="group grid h-6 place-items-center rounded-full px-[3px] outline-none focus-visible:ring-2 focus-visible:ring-(--color-focus)"            >              <motion.span                initial={false}                animate={{ width: i === index ? 18 : 6 }}                transition={reduce ? { duration: 0 } : { type: "spring", bounce: 0.2, duration: 0.35 }}                className={cn(                  "block h-1.5 rounded-full transition-colors duration-200",                  i === index                    ? "bg-(--color-ink)"                    : "bg-(--color-rule) group-hover:bg-(--color-neutral)",                )}              />            </button>          ))}        </div>        <NavButton label="Next" disabled={index === last} onClick={() => goTo(target.current + 1)}>          <ChevronRight />        </NavButton>      </div>    </div>  );}
function NavButton({  label,  disabled,  onClick,  children,}: {  label: string;  disabled: boolean;  onClick: () => void;  children: React.ReactNode;}) {  return (    <button      type="button"      aria-label={label}      disabled={disabled}      onClick={onClick}      className={cn(        "flex size-9 items-center justify-center rounded-full outline-none",        "bg-(--color-paper) text-(--color-ink) shadow-[inset_0_0_0_1px_var(--color-rule),0_1px_2px_rgba(0,0,0,0.05)]",        "transition-[transform,opacity,background-color] duration-150 ease-out",        "enabled:hover:bg-(--color-paper-2) enabled:active:scale-[0.94] disabled:opacity-35",        "focus-visible:ring-2 focus-visible:ring-(--color-focus)",        "[&_svg]:size-4 [&_svg]:stroke-[1.75]",      )}    >      {children}    </button>  );}

Adjust import paths according to your folder structure.


Basic Usage

tsx
import { Coverflow } from "@/components/ui/coverflow";
<Coverflow  initialIndex={1}  items={[    { id: "woxly", title: "Woxly", subtitle: "Commerce platform", image: "/images/landing-woxly.webp" },    { id: "velnor", title: "Velnor UI", subtitle: "Motion kit", image: "/images/velnor.webp" },    { id: "befit", title: "Befit AI", subtitle: "Meal planning", image: "/images/befit-ai.webp" },  ]}  onSelect={(item) => router.push(`/projects/${item.id}`)}/>

Props

PropTypeRequiredDefaultDescription
itemsCoverflowItem[]Yes—Covers with id, title, optional subtitle, image URL and a surface background used when there is no image.
initialIndexnumberNo0Cover centred on mount.
onIndexChange(index: number) => voidNo—Fires when the deck heads to a new cover.
onSelect(item: CoverflowItem, index: number) => voidNo—Clicking the centred cover, or pressing Enter, selects it. Side covers are brought to the centre first.
spacingnumberNo60% of card widthDistance from the centre cover to its first neighbour.
reflectionbooleanNotrueShow the mirrored floor reflection.
classNamestringNo—Class names on the root.

Best Practices

  1. Card size follows the container width, so give the component the full row.
  2. Use landscape images (around 4:3); they're cropped from the top.
  3. Keyboard: Left and Right step, Home and End jump to the ends, Enter selects.
  4. Touch scrolls the page vertically and drives the deck horizontally.
  5. Reduced motion flattens the 3D turn into a plain slide with scale and opacity.