Galleries & Media

Coverflow

A horizontal 3D coverflow — the center cover faces flat while neighbors rotate away in perspective; drag or arrow to move, with optional reflection.

Install

npx shadcn@latest add @paragon/coverflow

coverflow.tsx

"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>
  );
}