Overview

A coverflow stage: the active card faces forward while neighbors rotate on Y and recede in Z. Drag, arrows, or buttons retarget a critically damped spring — slight bounce only after a fast flick.


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, useState } from "react";import { motion, useReducedMotion } from "motion/react";import { cn } from "@/lib/utils";
export type CoverflowItem = {  id: string;  title: string;  subtitle?: string;  image?: string;  surface?: string;};
export type CoverflowProps = {  items: CoverflowItem[];  className?: string;  spacing?: number;  initialIndex?: number;  onIndexChange?: (index: number) => void;};
function useIsNarrow(breakpoint = 640) {  const [narrow, setNarrow] = useState(false);  useEffect(() => {    const mq = window.matchMedia(`(max-width: ${breakpoint}px)`);    const sync = () => setNarrow(mq.matches);    sync();    mq.addEventListener("change", sync);    return () => mq.removeEventListener("change", sync);  }, [breakpoint]);  return narrow;}
const DECEL = 0.998;
function project(velocity: number, decelerationRate = DECEL) {  return ((velocity / 1000) * decelerationRate) / (1 - decelerationRate);}
export function Coverflow({  items,  className,  spacing: spacingProp,  initialIndex = 0,  onIndexChange,}: CoverflowProps) {  const reduce = useReducedMotion();  const narrow = useIsNarrow();  // Near full-stage cards — real page screens, not postage stamps  const cardW = narrow ? 260 : 420;  const cardH = narrow ? 300 : 460;  const spacing = spacingProp ?? (narrow ? 180 : 280);
  const [index, setIndex] = useState(    Math.min(Math.max(initialIndex, 0), Math.max(items.length - 1, 0)),  );  const [dragX, setDragX] = useState(0);  const [dragging, setDragging] = useState(false);  const dragRef = React.useRef({    startX: 0,    samples: [] as { t: number; x: number }[],  });
  const goTo = useCallback(    (next: number) => {      const clamped = Math.min(Math.max(next, 0), items.length - 1);      setIndex(clamped);      setDragX(0);      setDragging(false);      onIndexChange?.(clamped);    },    [items.length, onIndexChange],  );
  const onPointerDown = (e: React.PointerEvent) => {    setDragging(true);    dragRef.current = {      startX: e.clientX,      samples: [{ t: performance.now(), x: e.clientX }],    };    (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);  };
  const onPointerMove = (e: React.PointerEvent) => {    if (!dragging || reduce) return;    const dx = e.clientX - dragRef.current.startX;    // Rubber-band past ends    const atStart = index <= 0 && dx > 0;    const atEnd = index >= items.length - 1 && dx < 0;    const damped =      atStart || atEnd        ? (dx * 88) / (88 + Math.abs(dx) * 0.55)        : dx;    setDragX(damped);    dragRef.current.samples.push({ t: performance.now(), x: e.clientX });    if (dragRef.current.samples.length > 5) dragRef.current.samples.shift();  };
  const onPointerUp = () => {    if (!dragging) return;    const samples = dragRef.current.samples;    let vx = 0;    if (samples.length >= 2) {      const a = samples[0];      const b = samples[samples.length - 1];      const dt = (b.t - a.t) / 1000;      if (dt > 0) vx = (b.x - a.x) / dt;    }    const projected = dragX + project(vx);    const step = Math.round(-projected / spacing);    goTo(index + step);  };
  const onKeyDown = (e: React.KeyboardEvent) => {    if (e.key === "ArrowLeft") {      e.preventDefault();      goTo(index - 1);    } else if (e.key === "ArrowRight") {      e.preventDefault();      goTo(index + 1);    }  };
  const spring = reduce    ? { duration: 0.15 }    : { type: "spring" as const, bounce: 0, duration: 0.3 };
  const active = items[index];
  return (    <div      className={cn(        "flex w-full flex-col items-center gap-6 sm:gap-8",        className,      )}    >      <div        role="listbox"        aria-label="Coverflow"        aria-activedescendant={active?.id}        tabIndex={0}        onKeyDown={onKeyDown}        onPointerDown={onPointerDown}        onPointerMove={onPointerMove}        onPointerUp={onPointerUp}        onPointerCancel={onPointerUp}        className={cn(          "relative w-full touch-none select-none outline-none",          "h-[320px] sm:h-[480px]",          "focus-visible:ring-2 focus-visible:ring-(--color-focus) focus-visible:ring-offset-4",        )}        style={{          perspective: reduce ? undefined : narrow ? 1100 : 1600,          perspectiveOrigin: "50% 40%",        }}      >        <div          className="absolute inset-0"          style={{ transformStyle: "preserve-3d" }}        >          {items.map((item, i) => {            const offset = i - index - dragX / spacing;            const abs = Math.abs(offset);            const x = offset * spacing;            const tilt = narrow ? 42 : 52;            const rotateY = reduce              ? 0              : Math.max(-tilt, Math.min(tilt, offset * -tilt));            const z = reduce ? 0 : -abs * (narrow ? 90 : 120);            const scale = reduce              ? i === index                ? 1                : 0.88              : Math.max(0.72, 1 - abs * 0.12);            const opacity = Math.max(0.22, 1 - abs * 0.32);            const isActive = Math.round(offset) === 0;
            return (              <motion.button                key={item.id}                id={item.id}                type="button"                role="option"                aria-selected={isActive}                aria-label={`${item.title}${item.subtitle ? `, ${item.subtitle}` : ""}`}                onClick={() => goTo(i)}                initial={false}                animate={{ x, rotateY, z, scale, opacity }}                transition={dragging ? { duration: 0 } : spring}                className={cn(                  "absolute top-1/2 left-1/2 flex flex-col overflow-hidden",                  "origin-center rounded-lg border border-(--color-rule) bg-(--color-paper)",                  "text-left shadow-[0_2px_8px_rgba(0,0,0,0.04),0_20px_48px_rgba(0,0,0,0.1)]",                  "dark:shadow-[0_20px_56px_rgba(0,0,0,0.45)]",                  "outline outline-1 outline-black/6 dark:outline-white/10",                  "focus-visible:outline-none",                )}                style={{                  width: cardW,                  height: cardH,                  marginLeft: -cardW / 2,                  marginTop: -cardH / 2,                  transformStyle: "preserve-3d",                  zIndex: items.length - Math.round(abs),                }}              >                <div className="relative min-h-0 flex-1 overflow-hidden bg-(--color-paper-2)">                  {item.image ? (                    // eslint-disable-next-line @next/next/no-img-element                    <img                      src={item.image}                      alt=""                      draggable={false}                      className="h-full w-full object-cover object-top"                    />                  ) : (                    <div                      className="h-full w-full"                      style={{                        background:                          item.surface ??                          "linear-gradient(145deg, var(--color-paper-2), var(--color-rule))",                      }}                    />                  )}                </div>                <div className="flex shrink-0 flex-col gap-0.5 border-t border-(--color-rule) px-4 py-3 sm:px-5 sm:py-3.5">                  <span className="truncate font-(family-name:--font-display) text-[15px] font-medium tracking-[-0.02em] text-(--color-ink) sm:text-base">                    {item.title}                  </span>                  {item.subtitle ? (                    <span className="truncate text-[12px] text-(--color-ink-2) sm:text-[13px]">                      {item.subtitle}                    </span>                  ) : null}                </div>              </motion.button>            );          })}        </div>      </div>
      <div className="flex flex-col items-center gap-3">        {active ? (          <p className="font-(family-name:--font-display) text-lg font-medium tracking-tight text-(--color-ink) sm:text-xl">            {active.title}          </p>        ) : null}        <div className="flex items-center gap-3">          <button            type="button"            aria-label="Previous"            disabled={index === 0}            onClick={() => goTo(index - 1)}            className={cn(              "flex min-h-10 min-w-10 items-center justify-center",              "rounded-sm border border-(--color-rule) bg-(--color-paper) text-sm text-(--color-ink)",              "transition-transform duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] enabled:active:scale-[0.96]",              "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-focus)",              "disabled:opacity-35",            )}          >          </button>          <span className="min-w-[5ch] text-center font-mono text-[11px] tabular-nums text-muted">            {index + 1}/{items.length}          </span>          <button            type="button"            aria-label="Next"            disabled={index === items.length - 1}            onClick={() => goTo(index + 1)}            className={cn(              "flex min-h-10 min-w-10 items-center justify-center",              "rounded-sm border border-(--color-rule) bg-(--color-paper) text-sm text-(--color-ink)",              "transition-transform duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] enabled:active:scale-[0.96]",              "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-focus)",              "disabled:opacity-35",            )}          >          </button>        </div>      </div>    </div>  );}

Adjust import paths according to your folder structure.


Basic Usage

tsx
import { Coverflow } from "@/components/ui/coverflow";
<Coverflow  items={[    { id: "1", title: "Woxly", subtitle: "Commerce", image: "/images/landing-woxly.png" },    { id: "2", title: "Velnor", subtitle: "Motion kit", image: "/images/velnor.png" },  ]}/>

Props

PropTypeRequiredDefaultDescription
itemsCoverflowItem[]YesSlides with id, title, optional subtitle, image URL, and optional surface fallback.
spacingnumberNo120Horizontal distance between card centers.
initialIndexnumberNo0Starting slide index.
onIndexChange(index: number) => voidNoFires when the active index changes.
classNamestringNoOptional class names on the root.

Best Practices

  1. Keep 3D motion on transform only — rotateY, translateZ, scale, opacity.
  2. Default springs critically damped; add bounce only when release velocity is high.
  3. Support ArrowLeft / ArrowRight and visible focus rings on the stage.
  4. Flatten perspective under reduced motion — scale and opacity carry hierarchy.