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.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, useId, useRef, useState } from "react";4import {5 AnimatePresence,6 animate,7 motion,8 useMotionValue,9 useMotionValueEvent,10 useReducedMotion,11 useTransform,12 type AnimationPlaybackControls,13 type MotionValue,14} from "motion/react";15import { ChevronLeft, ChevronRight } from "lucide-react";16import { cn } from "@/lib/utils";17 18export type CoverflowItem = {19 id: string;20 title: string;21 subtitle?: string;22 image?: string;23 /** CSS background used when there is no image. */24 surface?: string;25};26 27export type CoverflowProps = {28 items: CoverflowItem[];29 className?: string;30 /** Distance between the active card and its first neighbour, in px. Defaults to ~62% of the card width. */31 spacing?: number;32 initialIndex?: number;33 onIndexChange?: (index: number) => void;34 /** Called when the already-centred card is clicked or Enter is pressed. */35 onSelect?: (item: CoverflowItem, index: number) => void;36 /** Mirror the cards on the floor below. */37 reflection?: boolean;38};39 40const SETTLE = { type: "spring" as const, stiffness: 240, damping: 30, mass: 0.9 };41const EASE_OUT = [0.23, 1, 0.32, 1] as const;42 43const clamp = (v: number, min: number, max: number) => Math.min(max, Math.max(min, v));44/** Soft limit past the ends: the further you pull, the less it gives. */45const rubber = (over: number) => (0.4 * over) / (1 + over);46 47type Geometry = { cardW: number; cardH: number; spacing: number; tilt: number };48 49function CoverCard({50 item,51 index,52 position,53 geo,54 reduce,55 reflection,56 selected,57 optionId,58}: {59 item: CoverflowItem;60 index: number;61 position: MotionValue<number>;62 geo: Geometry;63 reduce: boolean;64 reflection: boolean;65 selected: boolean;66 optionId: string;67}) {68 const { cardW, cardH, spacing, tilt } = geo;69 const offset = useTransform(position, (p) => index - p);70 71 // First neighbour sits a full `spacing` away; the rest stack tighter72 // behind it, like records in a crate.73 const x = useTransform(offset, (o) => {74 const a = Math.abs(o);75 const d = a <= 1 ? a * spacing : spacing + (a - 1) * spacing * 0.42;76 return Math.sign(o) * d;77 });78 const rotateY = useTransform(offset, (o) => (reduce ? 0 : -clamp(o, -1, 1) * tilt));79 const z = useTransform(offset, (o) => {80 if (reduce) return 0;81 const a = Math.abs(o);82 return -Math.min(a, 1) * cardW * 0.42 - Math.max(0, a - 1) * 24;83 });84 const scale = useTransform(offset, (o) => (reduce ? 1 - Math.min(Math.abs(o), 1) * 0.12 : 1));85 const opacity = useTransform(offset, (o) => clamp(3.4 - Math.abs(o), 0, 1));86 const filter = useTransform(offset, (o) =>87 reduce ? "none" : `blur(${clamp((Math.abs(o) - 0.8) * 1.4, 0, 3.5).toFixed(2)}px)`,88 );89 const zIndex = useTransform(offset, (o) => 1000 - Math.round(Math.abs(o) * 10));90 // Turned-away edges fall into shadow; the near edge catches light.91 const shadeRight = useTransform(offset, (o) => clamp(o, 0, 1) * 0.55);92 const shadeLeft = useTransform(offset, (o) => clamp(-o, 0, 1) * 0.55);93 const [loaded, setLoaded] = useState(false);94 95 const face = (96 <div className="absolute inset-0 overflow-hidden rounded-[14px] bg-(--color-paper-2)">97 {item.image ? (98 // eslint-disable-next-line @next/next/no-img-element99 <img100 src={item.image}101 alt=""102 draggable={false}103 decoding="async"104 onLoad={() => setLoaded(true)}105 ref={(el) => {106 if (el?.complete && el.naturalWidth) setLoaded(true);107 }}108 className={cn(109 "size-full object-cover object-top transition-[opacity,filter] duration-500",110 loaded ? "opacity-100 blur-0" : "opacity-0 blur-md",111 )}112 />113 ) : (114 <div115 className="size-full"116 style={{117 background:118 item.surface ?? "linear-gradient(145deg, var(--color-paper-2), var(--color-rule))",119 }}120 />121 )}122 </div>123 );124 125 return (126 <motion.div127 id={optionId}128 role="option"129 aria-selected={selected}130 aria-label={`${item.title}${item.subtitle ? `, ${item.subtitle}` : ""}`}131 data-coverflow-index={index}132 className="absolute top-0 left-1/2 cursor-pointer"133 style={{134 width: cardW,135 height: cardH,136 marginLeft: -cardW / 2,137 x,138 z,139 rotateY,140 scale,141 opacity,142 filter,143 zIndex,144 }}145 >146 <div147 className={cn(148 "relative size-full rounded-[14px]",149 "shadow-[0_1px_2px_rgba(0,0,0,0.06),0_24px_48px_-16px_rgba(0,0,0,0.35)]",150 "dark:shadow-[0_24px_56px_-16px_rgba(0,0,0,0.8)]",151 )}152 >153 {face}154 {/* Gloss + hairline */}155 <div156 aria-hidden157 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)]"158 />159 <motion.div160 aria-hidden161 style={{ opacity: shadeRight }}162 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))]"163 />164 <motion.div165 aria-hidden166 style={{ opacity: shadeLeft }}167 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))]"168 />169 </div>170 171 {reflection ? (172 <div173 aria-hidden174 className="pointer-events-none absolute inset-x-0 top-full mt-1.5 -scale-y-100 opacity-20 dark:opacity-15"175 style={{176 height: cardH,177 maskImage: "linear-gradient(to top, rgba(0,0,0,0.9), transparent 42%)",178 WebkitMaskImage: "linear-gradient(to top, rgba(0,0,0,0.9), transparent 42%)",179 }}180 >181 {face}182 </div>183 ) : null}184 </motion.div>185 );186}187 188export function Coverflow({189 items,190 className,191 spacing: spacingProp,192 initialIndex = 0,193 onIndexChange,194 onSelect,195 reflection = true,196}: CoverflowProps) {197 const reduce = !!useReducedMotion();198 const uid = useId();199 const last = Math.max(items.length - 1, 0);200 const start = clamp(initialIndex, 0, last);201 202 const stageRef = useRef<HTMLDivElement>(null);203 const [width, setWidth] = useState(720);204 const position = useMotionValue(start);205 const [index, setIndex] = useState(start);206 const [direction, setDirection] = useState(1);207 const anim = useRef<AnimationPlaybackControls | null>(null);208 const target = useRef(start);209 const drag = useRef<{210 x: number;211 from: number;212 pointerId: number;213 moved: boolean;214 hit: number | null;215 } | null>(null);216 const [grabbing, setGrabbing] = useState(false);217 218 useEffect(() => {219 const el = stageRef.current;220 if (!el) return;221 const ro = new ResizeObserver(([entry]) => setWidth(entry.contentRect.width));222 ro.observe(el);223 return () => ro.disconnect();224 }, []);225 226 const cardW = Math.round(clamp(width * 0.5, 200, 400));227 const cardH = Math.round(cardW * 0.75);228 const geo: Geometry = {229 cardW,230 cardH,231 spacing: spacingProp ?? Math.round(cardW * 0.6),232 tilt: width < 520 ? 44 : 52,233 };234 235 const indexRef = useRef(start);236 useMotionValueEvent(position, "change", (v) => {237 const next = clamp(Math.round(v), 0, last);238 if (next === indexRef.current) return;239 setDirection(next > indexRef.current ? 1 : -1);240 indexRef.current = next;241 setIndex(next);242 });243 244 const goTo = useCallback(245 (next: number, velocity = 0) => {246 const to = clamp(Math.round(next), 0, last);247 anim.current?.stop();248 anim.current = animate(249 position,250 to,251 reduce ? { duration: 0.2, ease: EASE_OUT } : { ...SETTLE, velocity },252 );253 if (to !== target.current) {254 target.current = to;255 onIndexChange?.(to);256 }257 },258 [last, onIndexChange, position, reduce],259 );260 261 /* -------------------------------- pointer -------------------------------- */262 263 const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {264 if (!e.isPrimary || e.button !== 0) return;265 anim.current?.stop();266 const hit = (e.target as Element).closest<HTMLElement>("[data-coverflow-index]");267 drag.current = {268 x: e.clientX,269 from: position.get(),270 pointerId: e.pointerId,271 moved: false,272 hit: hit ? Number(hit.dataset.coverflowIndex) : null,273 };274 };275 276 const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {277 const d = drag.current;278 if (!d || d.pointerId !== e.pointerId) return;279 const dx = e.clientX - d.x;280 if (!d.moved) {281 if (Math.abs(dx) < 5) return;282 d.moved = true;283 setGrabbing(true);284 try {285 e.currentTarget.setPointerCapture(e.pointerId);286 } catch {287 /* Pointer already gone; window-level moves still arrive. */288 }289 }290 let p = d.from - dx / geo.spacing;291 if (p < 0) p = -rubber(-p);292 else if (p > last) p = last + rubber(p - last);293 position.set(p);294 };295 296 const onPointerUp = (e: React.PointerEvent<HTMLDivElement>) => {297 const d = drag.current;298 drag.current = null;299 if (!d || d.pointerId !== e.pointerId) return;300 setGrabbing(false);301 if (!d.moved) {302 // A tap: centre a side card, or select the one already centred.303 if (d.hit === null) return goTo(target.current);304 if (d.hit === target.current) {305 goTo(d.hit);306 onSelect?.(items[d.hit], d.hit);307 } else goTo(d.hit);308 return;309 }310 // Project the flick forward, then settle on the nearest card.311 const v = position.getVelocity();312 goTo(position.get() + v * 0.22, v);313 };314 315 // Trackpad horizontal scroll scrubs the deck, then snaps.316 useEffect(() => {317 const el = stageRef.current;318 if (!el) return;319 let timer = 0;320 const onWheel = (e: WheelEvent) => {321 if (Math.abs(e.deltaX) <= Math.abs(e.deltaY) && !e.shiftKey) return;322 e.preventDefault();323 anim.current?.stop();324 const delta = (e.shiftKey && !e.deltaX ? e.deltaY : e.deltaX) / (geo.spacing * 1.1);325 position.set(clamp(position.get() + delta, -0.3, last + 0.3));326 window.clearTimeout(timer);327 timer = window.setTimeout(() => goTo(position.get()), 120);328 };329 el.addEventListener("wheel", onWheel, { passive: false });330 return () => {331 el.removeEventListener("wheel", onWheel);332 window.clearTimeout(timer);333 };334 }, [geo.spacing, goTo, last, position]);335 336 const onKeyDown = (e: React.KeyboardEvent) => {337 const keys: Record<string, number> = {338 ArrowLeft: target.current - 1,339 ArrowRight: target.current + 1,340 Home: 0,341 End: last,342 };343 if (e.key in keys) {344 e.preventDefault();345 goTo(keys[e.key]);346 } else if ((e.key === "Enter" || e.key === " ") && onSelect) {347 e.preventDefault();348 onSelect(items[target.current], target.current);349 }350 };351 352 if (!items.length) return null;353 const active = items[index];354 const stageH = cardH + (reflection ? Math.round(cardH * 0.4) : 24);355 356 return (357 <div className={cn("flex w-full flex-col items-center gap-5", className)}>358 <div359 ref={stageRef}360 role="listbox"361 aria-label="Coverflow"362 aria-orientation="horizontal"363 aria-activedescendant={`${uid}-opt-${index}`}364 tabIndex={0}365 onKeyDown={onKeyDown}366 onPointerDown={onPointerDown}367 onPointerMove={onPointerMove}368 onPointerUp={onPointerUp}369 onPointerCancel={onPointerUp}370 className={cn(371 "relative w-full touch-pan-y overflow-hidden rounded-[18px] outline-none select-none",372 "focus-visible:ring-2 focus-visible:ring-(--color-focus) focus-visible:ring-offset-4 focus-visible:ring-offset-(--color-paper)",373 grabbing ? "cursor-grabbing" : "cursor-grab",374 )}375 style={{376 height: stageH + 32,377 paddingTop: 16,378 // Fade the far cards into the page at both sides.379 maskImage: "linear-gradient(90deg, transparent, #000 12%, #000 88%, transparent)",380 WebkitMaskImage: "linear-gradient(90deg, transparent, #000 12%, #000 88%, transparent)",381 }}382 >383 <div384 className="relative h-full"385 style={{ perspective: cardW * 3.6, perspectiveOrigin: "50% 40%" }}386 >387 {items.map((item, i) => (388 <CoverCard389 key={item.id}390 item={item}391 index={i}392 position={position}393 geo={geo}394 reduce={reduce}395 reflection={reflection}396 selected={i === index}397 optionId={`${uid}-opt-${i}`}398 />399 ))}400 </div>401 </div>402 403 {/* Caption */}404 <div className="relative grid h-12 w-full place-items-center overflow-hidden text-center" aria-live="polite">405 <AnimatePresence initial={false} mode="popLayout" custom={direction}>406 <motion.div407 key={active.id}408 custom={direction}409 variants={{410 enter: (dir: number) =>411 reduce ? { opacity: 0 } : { opacity: 0, x: dir * 24, filter: "blur(6px)" },412 center: { opacity: 1, x: 0, filter: "blur(0px)" },413 exit: (dir: number) =>414 reduce ? { opacity: 0 } : { opacity: 0, x: dir * -24, filter: "blur(6px)" },415 }}416 initial="enter"417 animate="center"418 exit="exit"419 transition={{ duration: 0.32, ease: EASE_OUT }}420 className="col-start-1 row-start-1"421 >422 <div className="font-(family-name:--font-display) text-[17px] font-semibold tracking-[-0.015em] text-(--color-ink)">423 {active.title}424 </div>425 {active.subtitle ? (426 <div className="mt-0.5 text-[13px] text-(--color-neutral)">{active.subtitle}</div>427 ) : null}428 </motion.div>429 </AnimatePresence>430 </div>431 432 {/* Controls */}433 <div className="flex items-center gap-3">434 <NavButton label="Previous" disabled={index === 0} onClick={() => goTo(target.current - 1)}>435 <ChevronLeft />436 </NavButton>437 <div className="flex items-center gap-1 rounded-full px-1.5 py-1">438 {items.map((item, i) => (439 <button440 key={item.id}441 type="button"442 aria-label={`Show ${item.title}`}443 aria-current={i === index ? "true" : undefined}444 onClick={() => goTo(i)}445 className="group grid h-6 place-items-center rounded-full px-[3px] outline-none focus-visible:ring-2 focus-visible:ring-(--color-focus)"446 >447 <motion.span448 initial={false}449 animate={{ width: i === index ? 18 : 6 }}450 transition={reduce ? { duration: 0 } : { type: "spring", bounce: 0.2, duration: 0.35 }}451 className={cn(452 "block h-1.5 rounded-full transition-colors duration-200",453 i === index454 ? "bg-(--color-ink)"455 : "bg-(--color-rule) group-hover:bg-(--color-neutral)",456 )}457 />458 </button>459 ))}460 </div>461 <NavButton label="Next" disabled={index === last} onClick={() => goTo(target.current + 1)}>462 <ChevronRight />463 </NavButton>464 </div>465 </div>466 );467}468 469function NavButton({470 label,471 disabled,472 onClick,473 children,474}: {475 label: string;476 disabled: boolean;477 onClick: () => void;478 children: React.ReactNode;479}) {480 return (481 <button482 type="button"483 aria-label={label}484 disabled={disabled}485 onClick={onClick}486 className={cn(487 "flex size-9 items-center justify-center rounded-full outline-none",488 "bg-(--color-paper) text-(--color-ink) shadow-[inset_0_0_0_1px_var(--color-rule),0_1px_2px_rgba(0,0,0,0.05)]",489 "transition-[transform,opacity,background-color] duration-150 ease-out",490 "enabled:hover:bg-(--color-paper-2) enabled:active:scale-[0.94] disabled:opacity-35",491 "focus-visible:ring-2 focus-visible:ring-(--color-focus)",492 "[&_svg]:size-4 [&_svg]:stroke-[1.75]",493 )}494 >495 {children}496 </button>497 );498}
Adjust import paths according to your folder structure.
Basic Usage
tsx
1import { Coverflow } from "@/components/ui/coverflow";2 3<Coverflow4 initialIndex={1}5 items={[6 { id: "woxly", title: "Woxly", subtitle: "Commerce platform", image: "/images/landing-woxly.webp" },7 { id: "velnor", title: "Velnor UI", subtitle: "Motion kit", image: "/images/velnor.webp" },8 { id: "befit", title: "Befit AI", subtitle: "Meal planning", image: "/images/befit-ai.webp" },9 ]}10 onSelect={(item) => router.push(`/projects/${item.id}`)}11/>
Props
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
| items | CoverflowItem[] | Yes | — | Covers with id, title, optional subtitle, image URL and a surface background used when there is no image. |
| initialIndex | number | No | 0 | Cover centred on mount. |
| onIndexChange | (index: number) => void | No | — | Fires when the deck heads to a new cover. |
| onSelect | (item: CoverflowItem, index: number) => void | No | — | Clicking the centred cover, or pressing Enter, selects it. Side covers are brought to the centre first. |
| spacing | number | No | 60% of card width | Distance from the centre cover to its first neighbour. |
| reflection | boolean | No | true | Show the mirrored floor reflection. |
| className | string | No | — | Class names on the root. |
Best Practices
- Card size follows the container width, so give the component the full row.
- Use landscape images (around 4:3); they're cropped from the top.
- Keyboard: Left and Right step, Home and End jump to the ends, Enter selects.
- Touch scrolls the page vertically and drives the deck horizontally.
- Reduced motion flattens the 3D turn into a plain slide with scale and opacity.