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.jsonInstall dependencies:
$ pnpm add motionAdd the utility function for class merging:
lib/utils.ts
tsx
1import { ClassValue, clsx } from "clsx";2import { twMerge } from "tailwind-merge";3 4export function cn(...inputs: ClassValue[]) {5 return twMerge(clsx(inputs));6}
Copy the component code into your project:
tsx
1"use client";2 3import React, { useCallback, useEffect, useState } from "react";4import { motion, useReducedMotion } from "motion/react";5import { cn } from "@/lib/utils";6 7export type CoverflowItem = {8 id: string;9 title: string;10 subtitle?: string;11 image?: string;12 surface?: string;13};14 15export type CoverflowProps = {16 items: CoverflowItem[];17 className?: string;18 spacing?: number;19 initialIndex?: number;20 onIndexChange?: (index: number) => void;21};22 23function useIsNarrow(breakpoint = 640) {24 const [narrow, setNarrow] = useState(false);25 useEffect(() => {26 const mq = window.matchMedia(`(max-width: ${breakpoint}px)`);27 const sync = () => setNarrow(mq.matches);28 sync();29 mq.addEventListener("change", sync);30 return () => mq.removeEventListener("change", sync);31 }, [breakpoint]);32 return narrow;33}34 35const DECEL = 0.998;36 37function project(velocity: number, decelerationRate = DECEL) {38 return ((velocity / 1000) * decelerationRate) / (1 - decelerationRate);39}40 41export function Coverflow({42 items,43 className,44 spacing: spacingProp,45 initialIndex = 0,46 onIndexChange,47}: CoverflowProps) {48 const reduce = useReducedMotion();49 const narrow = useIsNarrow();50 // Near full-stage cards — real page screens, not postage stamps51 const cardW = narrow ? 260 : 420;52 const cardH = narrow ? 300 : 460;53 const spacing = spacingProp ?? (narrow ? 180 : 280);54 55 const [index, setIndex] = useState(56 Math.min(Math.max(initialIndex, 0), Math.max(items.length - 1, 0)),57 );58 const [dragX, setDragX] = useState(0);59 const [dragging, setDragging] = useState(false);60 const dragRef = React.useRef({61 startX: 0,62 samples: [] as { t: number; x: number }[],63 });64 65 const goTo = useCallback(66 (next: number) => {67 const clamped = Math.min(Math.max(next, 0), items.length - 1);68 setIndex(clamped);69 setDragX(0);70 setDragging(false);71 onIndexChange?.(clamped);72 },73 [items.length, onIndexChange],74 );75 76 const onPointerDown = (e: React.PointerEvent) => {77 setDragging(true);78 dragRef.current = {79 startX: e.clientX,80 samples: [{ t: performance.now(), x: e.clientX }],81 };82 (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);83 };84 85 const onPointerMove = (e: React.PointerEvent) => {86 if (!dragging || reduce) return;87 const dx = e.clientX - dragRef.current.startX;88 // Rubber-band past ends89 const atStart = index <= 0 && dx > 0;90 const atEnd = index >= items.length - 1 && dx < 0;91 const damped =92 atStart || atEnd93 ? (dx * 88) / (88 + Math.abs(dx) * 0.55)94 : dx;95 setDragX(damped);96 dragRef.current.samples.push({ t: performance.now(), x: e.clientX });97 if (dragRef.current.samples.length > 5) dragRef.current.samples.shift();98 };99 100 const onPointerUp = () => {101 if (!dragging) return;102 const samples = dragRef.current.samples;103 let vx = 0;104 if (samples.length >= 2) {105 const a = samples[0];106 const b = samples[samples.length - 1];107 const dt = (b.t - a.t) / 1000;108 if (dt > 0) vx = (b.x - a.x) / dt;109 }110 const projected = dragX + project(vx);111 const step = Math.round(-projected / spacing);112 goTo(index + step);113 };114 115 const onKeyDown = (e: React.KeyboardEvent) => {116 if (e.key === "ArrowLeft") {117 e.preventDefault();118 goTo(index - 1);119 } else if (e.key === "ArrowRight") {120 e.preventDefault();121 goTo(index + 1);122 }123 };124 125 const spring = reduce126 ? { duration: 0.15 }127 : { type: "spring" as const, bounce: 0, duration: 0.3 };128 129 const active = items[index];130 131 return (132 <div133 className={cn(134 "flex w-full flex-col items-center gap-6 sm:gap-8",135 className,136 )}137 >138 <div139 role="listbox"140 aria-label="Coverflow"141 aria-activedescendant={active?.id}142 tabIndex={0}143 onKeyDown={onKeyDown}144 onPointerDown={onPointerDown}145 onPointerMove={onPointerMove}146 onPointerUp={onPointerUp}147 onPointerCancel={onPointerUp}148 className={cn(149 "relative w-full touch-none select-none outline-none",150 "h-[320px] sm:h-[480px]",151 "focus-visible:ring-2 focus-visible:ring-(--color-focus) focus-visible:ring-offset-4",152 )}153 style={{154 perspective: reduce ? undefined : narrow ? 1100 : 1600,155 perspectiveOrigin: "50% 40%",156 }}157 >158 <div159 className="absolute inset-0"160 style={{ transformStyle: "preserve-3d" }}161 >162 {items.map((item, i) => {163 const offset = i - index - dragX / spacing;164 const abs = Math.abs(offset);165 const x = offset * spacing;166 const tilt = narrow ? 42 : 52;167 const rotateY = reduce168 ? 0169 : Math.max(-tilt, Math.min(tilt, offset * -tilt));170 const z = reduce ? 0 : -abs * (narrow ? 90 : 120);171 const scale = reduce172 ? i === index173 ? 1174 : 0.88175 : Math.max(0.72, 1 - abs * 0.12);176 const opacity = Math.max(0.22, 1 - abs * 0.32);177 const isActive = Math.round(offset) === 0;178 179 return (180 <motion.button181 key={item.id}182 id={item.id}183 type="button"184 role="option"185 aria-selected={isActive}186 aria-label={`${item.title}${item.subtitle ? `, ${item.subtitle}` : ""}`}187 onClick={() => goTo(i)}188 initial={false}189 animate={{ x, rotateY, z, scale, opacity }}190 transition={dragging ? { duration: 0 } : spring}191 className={cn(192 "absolute top-1/2 left-1/2 flex flex-col overflow-hidden",193 "origin-center rounded-lg border border-(--color-rule) bg-(--color-paper)",194 "text-left shadow-[0_2px_8px_rgba(0,0,0,0.04),0_20px_48px_rgba(0,0,0,0.1)]",195 "dark:shadow-[0_20px_56px_rgba(0,0,0,0.45)]",196 "outline outline-1 outline-black/6 dark:outline-white/10",197 "focus-visible:outline-none",198 )}199 style={{200 width: cardW,201 height: cardH,202 marginLeft: -cardW / 2,203 marginTop: -cardH / 2,204 transformStyle: "preserve-3d",205 zIndex: items.length - Math.round(abs),206 }}207 >208 <div className="relative min-h-0 flex-1 overflow-hidden bg-(--color-paper-2)">209 {item.image ? (210 // eslint-disable-next-line @next/next/no-img-element211 <img212 src={item.image}213 alt=""214 draggable={false}215 className="h-full w-full object-cover object-top"216 />217 ) : (218 <div219 className="h-full w-full"220 style={{221 background:222 item.surface ??223 "linear-gradient(145deg, var(--color-paper-2), var(--color-rule))",224 }}225 />226 )}227 </div>228 <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">229 <span className="truncate font-(family-name:--font-display) text-[15px] font-medium tracking-[-0.02em] text-(--color-ink) sm:text-base">230 {item.title}231 </span>232 {item.subtitle ? (233 <span className="truncate text-[12px] text-(--color-ink-2) sm:text-[13px]">234 {item.subtitle}235 </span>236 ) : null}237 </div>238 </motion.button>239 );240 })}241 </div>242 </div>243 244 <div className="flex flex-col items-center gap-3">245 {active ? (246 <p className="font-(family-name:--font-display) text-lg font-medium tracking-tight text-(--color-ink) sm:text-xl">247 {active.title}248 </p>249 ) : null}250 <div className="flex items-center gap-3">251 <button252 type="button"253 aria-label="Previous"254 disabled={index === 0}255 onClick={() => goTo(index - 1)}256 className={cn(257 "flex min-h-10 min-w-10 items-center justify-center",258 "rounded-sm border border-(--color-rule) bg-(--color-paper) text-sm text-(--color-ink)",259 "transition-transform duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] enabled:active:scale-[0.96]",260 "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-focus)",261 "disabled:opacity-35",262 )}263 >264 ←265 </button>266 <span className="min-w-[5ch] text-center font-mono text-[11px] tabular-nums text-muted">267 {index + 1}/{items.length}268 </span>269 <button270 type="button"271 aria-label="Next"272 disabled={index === items.length - 1}273 onClick={() => goTo(index + 1)}274 className={cn(275 "flex min-h-10 min-w-10 items-center justify-center",276 "rounded-sm border border-(--color-rule) bg-(--color-paper) text-sm text-(--color-ink)",277 "transition-transform duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] enabled:active:scale-[0.96]",278 "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-focus)",279 "disabled:opacity-35",280 )}281 >282 →283 </button>284 </div>285 </div>286 </div>287 );288}
Adjust import paths according to your folder structure.
Basic Usage
tsx
1import { Coverflow } from "@/components/ui/coverflow";2 3<Coverflow4 items={[5 { id: "1", title: "Woxly", subtitle: "Commerce", image: "/images/landing-woxly.png" },6 { id: "2", title: "Velnor", subtitle: "Motion kit", image: "/images/velnor.png" },7 ]}8/>
Props
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
| items | CoverflowItem[] | Yes | — | Slides with id, title, optional subtitle, image URL, and optional surface fallback. |
| spacing | number | No | 120 | Horizontal distance between card centers. |
| initialIndex | number | No | 0 | Starting slide index. |
| onIndexChange | (index: number) => void | No | — | Fires when the active index changes. |
| className | string | No | — | Optional class names on the root. |
Best Practices
- Keep 3D motion on
transformonly — rotateY, translateZ, scale, opacity. - Default springs critically damped; add bounce only when release velocity is high.
- Support ArrowLeft / ArrowRight and visible focus rings on the stage.
- Flatten perspective under reduced motion — scale and opacity carry hierarchy.