Get Started
Install the component via CLI or copy the source code directly into your project.
Usage











Kanao Tsuyuri
import { CoverFlow, type CoverFlowItem } from "@/components/ui/coverflow";
const animeItems: CoverFlowItem[] = [
{ id: 1, image: "/anime/Sanemi.jpeg", title: "Sanemi Sanemi" },
{ id: 2, image: "/anime/Obanai.jpeg", title: "Obanai Iguro" },
{ id: 3, image: "/anime/Mitsuri.jpeg", title: "Mitsuri Kanroji" },
{ id: 4, image: "/anime/giyu.jpeg", title: "Giyu Tomioka" },
{ id: 5, image: "/anime/Shinobu.jpeg", title: "Shinobu Kocho" },
{ id: 6, image: "/anime/kanao.jpeg", title: "Kanao Tsuyuri" },
{ id: 7, image: "/anime/Tanjiro.jpeg", title: "Tanjiro Kamado" },
{ id: 8, image: "/anime/Nezuko.jpeg", title: "Nezuko Kamado" },
{ id: 9, image: "/anime/Zenitsu.jpeg", title: "Zenitsu Agatsuma" },
{ id: 10, image: "/anime/Inosuke.jpeg", title: "Inosuke Hashibira" },
{ id: 11, image: "/anime/tokitou.jpeg", title: "Muichiro Tokito" },
];
export default function CoverFlowDemo() {
return (
<div className="h-[400px] w-full relative bg-background">
<CoverFlow
items={animeItems}
itemWidth={250}
itemHeight={250}
initialIndex={5}
enableScroll={true}
scrollThreshold={60}
centerGap={180}
stackSpacing={60}
rotation={50}
enableReflection={true}
enableClickToSnap={true}
enableAudio={false}
/>
</div>
);
}
Installation
pnpm dlx shadcn@latest add https://coverflow.ashishgogula.in/r/coverflow.json
Manual
1. Install dependencies:
npm install motion
2. Copy the component code into components/coverflow.tsx
coverflow.tsx
'use client'
import { memo, useCallback, useEffect, useId, useRef, useState, type ReactNode } from 'react'
import {
AnimatePresence,
motion,
useMotionValue,
useReducedMotion,
useSpring,
useTransform,
type PanInfo,
type MotionValue,
} from 'motion/react'
type Direction = 'left' | 'right'
const AudioCtx: typeof AudioContext | null =
typeof window !== 'undefined'
? (window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext ?? null)
: null
function useTickAudio(enabled: boolean) {
const ctxRef = useRef<AudioContext | null>(null)
useEffect(() => () => { ctxRef.current?.close().catch(() => {}); ctxRef.current = null }, [])
useEffect(() => {
if (!enabled || !AudioCtx) return
const warm = () => {
if (!ctxRef.current) ctxRef.current = new AudioCtx!()
if (ctxRef.current.state === 'suspended') ctxRef.current.resume().catch(() => {})
}
window.addEventListener('pointerdown', warm, { once: true })
return () => window.removeEventListener('pointerdown', warm)
}, [enabled])
return useCallback(
(direction: Direction, velocity = 1) => {
if (!enabled || !AudioCtx) return
const getCtx = async () => {
if (!ctxRef.current) ctxRef.current = new AudioCtx!()
if (ctxRef.current.state === 'suspended') await ctxRef.current.resume()
return ctxRef.current
}
getCtx().then((ctx) => {
const t = ctx.currentTime
const vn = Math.min(Math.abs(velocity) / 300, 1)
const peakGain = 0.28 * (0.55 + vn * 0.45)
const freq = 1600 * (0.88 + vn * 0.24)
const bodyDur = 0.022 - vn * 0.008
const clickDur = bodyDur * 0.3
const panStart = direction === 'left' ? 0.7 : -0.7
const panEnd = direction === 'left' ? -0.7 : 0.7
const panner = ctx.createStereoPanner()
panner.pan.setValueAtTime(panStart, t)
panner.pan.linearRampToValueAtTime(panEnd, t + bodyDur)
panner.connect(ctx.destination)
const bodyGain = ctx.createGain()
bodyGain.gain.setValueAtTime(peakGain, t)
bodyGain.gain.exponentialRampToValueAtTime(0.0001, t + bodyDur)
bodyGain.connect(panner)
const filter = ctx.createBiquadFilter()
filter.type = 'bandpass'
filter.frequency.value = freq
filter.Q.value = 6
filter.connect(bodyGain)
const osc = ctx.createOscillator()
osc.type = 'triangle'
osc.frequency.setValueAtTime(freq * 1.25, t)
osc.frequency.exponentialRampToValueAtTime(freq * 0.65, t + bodyDur)
osc.connect(filter)
osc.start(t)
osc.stop(t + bodyDur)
const nSamples = Math.ceil(ctx.sampleRate * clickDur)
const noiseBuf = ctx.createBuffer(1, nSamples, ctx.sampleRate)
const d = noiseBuf.getChannelData(0)
for (let i = 0; i < nSamples; i++) d[i] = (Math.random() * 2 - 1) * Math.exp(-i / (nSamples * 0.2))
const noiseGain = ctx.createGain()
noiseGain.gain.setValueAtTime(peakGain * 0.35, t)
noiseGain.gain.exponentialRampToValueAtTime(0.0001, t + clickDur)
noiseGain.connect(panner)
const noiseHp = ctx.createBiquadFilter()
noiseHp.type = 'highpass'
noiseHp.frequency.value = 2400
noiseHp.connect(noiseGain)
const noise = ctx.createBufferSource()
noise.buffer = noiseBuf
noise.connect(noiseHp)
noise.start(t)
noise.stop(t + clickDur)
}).catch(() => {})
},
[enabled],
)
}
export interface CoverFlowItem {
id: string | number
image: string
title: string
subtitle?: string
}
export interface RenderImageProps {
src: string
alt: string
width: number
height: number
className: string
draggable: boolean
sizes: string
priority?: boolean
loading?: 'eager' | 'lazy'
}
export interface CoverFlowProps {
items: CoverFlowItem[]
itemWidth?: number
itemHeight?: number
stackSpacing?: number
centerGap?: number
rotation?: number
initialIndex?: number
enableReflection?: boolean
enableClickToSnap?: boolean
enableScroll?: boolean
enableAudio?: boolean
scrollThreshold?: number
className?: string
onItemClick?: (item: CoverFlowItem, index: number) => void
onIndexChange?: (index: number) => void
renderImage?: (props: RenderImageProps) => ReactNode
}
const defaultRenderImage = (props: RenderImageProps) => (
<img
src={props.src}
alt={props.alt}
width={props.width}
height={props.height}
className={props.className}
draggable={props.draggable}
sizes={props.sizes}
loading={props.loading}
/>
)
function clampIndex(index: number, length: number) {
return Math.min(Math.max(index, 0), Math.max(length - 1, 0))
}
export function CoverFlow({
items,
itemWidth = 400,
itemHeight = 400,
stackSpacing = 100,
centerGap = 250,
rotation = 50,
initialIndex = 0,
enableReflection = false,
enableClickToSnap = true,
enableScroll = true,
enableAudio = false,
scrollThreshold = 100,
className,
onItemClick,
onIndexChange,
renderImage,
}: CoverFlowProps) {
const safeInitial = clampIndex(initialIndex, items.length)
const [activeIndex, setActiveIndex] = useState(safeInitial)
const [isDragging, setIsDragging] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const instanceId = useId().replace(/:/g, 'x')
const [isMounted, setIsMounted] = useState(false)
const [isMobile, setIsMobile] = useState(false)
const [isSafari, setIsSafari] = useState(false)
const [containerWidth, setContainerWidth] = useState(0)
useEffect(() => { setIsMounted(true) }, [])
useEffect(() => {
const container = containerRef.current
if (!container) return
const ro = new ResizeObserver(([entry]) => {
setContainerWidth(entry.contentRect.width)
})
ro.observe(container)
return () => ro.disconnect()
}, [])
useEffect(() => {
if (typeof window === 'undefined' || !window.matchMedia) return
const mql = window.matchMedia('(max-width: 768px), (pointer: coarse)')
const apply = () => setIsMobile(mql.matches)
apply()
mql.addEventListener?.('change', apply)
return () => mql.removeEventListener?.('change', apply)
}, [])
useEffect(() => {
if (typeof window === 'undefined') return
setIsSafari(/^((?!chrome|android).)*safari/i.test(window.navigator.userAgent))
}, [])
const scale = containerWidth > 0 && itemWidth > 0 ? Math.min(1, (containerWidth * 0.78) / itemWidth) : 1
const effectiveWidth = Math.round(itemWidth * scale)
const effectiveHeight = Math.round(itemHeight * scale)
const effectiveStackSpacing = Math.round(stackSpacing * scale)
const effectiveCenterGap = Math.round(centerGap * scale)
const reflectionFilterId = (isMounted && enableReflection && !isMobile && !isSafari) ? `${instanceId}-rf` : undefined
const showReflection = isMounted && enableReflection
const activeIndexRef = useRef(activeIndex)
const enableScrollRef = useRef(enableScroll)
const scrollThresholdRef = useRef(scrollThreshold)
const onItemClickRef = useRef(onItemClick)
const enableClickToSnapRef = useRef(enableClickToSnap)
const onIndexChangeRef = useRef(onIndexChange)
const isMountedForCallbackRef = useRef(false)
activeIndexRef.current = activeIndex
enableScrollRef.current = enableScroll
scrollThresholdRef.current = scrollThreshold
onItemClickRef.current = onItemClick
enableClickToSnapRef.current = enableClickToSnap
onIndexChangeRef.current = onIndexChange
const prefersReducedMotion = useReducedMotion()
const scrollX = useMotionValue(safeInitial)
const springX = useSpring(scrollX, { stiffness: 150, damping: 30, mass: 1 })
const effectiveScrollX = prefersReducedMotion ? scrollX : springX
const tick = useTickAudio(enableAudio)
useEffect(() => {
const clamped = clampIndex(initialIndex, items.length)
if (clamped !== activeIndexRef.current) {
setActiveIndex(clamped)
scrollX.set(clamped)
}
}, [initialIndex, items.length, scrollX])
useEffect(() => {
if (!isMountedForCallbackRef.current) { isMountedForCallbackRef.current = true; return }
onIndexChangeRef.current?.(activeIndex)
}, [activeIndex])
const jumpToIndex = useCallback(
(index: number, velocity = 0, direction?: Direction) => {
const clamped = clampIndex(index, items.length)
const prev = activeIndexRef.current
if (clamped === prev) return
const dir: Direction = direction ?? (clamped > prev ? 'right' : 'left')
setActiveIndex(clamped)
scrollX.set(clamped)
tick(dir, velocity)
},
[items.length, scrollX, tick],
)
useEffect(() => {
const container = containerRef.current
if (!container) return
let accumulator = 0
let lastTime = Date.now()
let lastJump = 0
const handleWheel = (e: WheelEvent) => {
if (!enableScrollRef.current) return
if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) return
e.preventDefault()
const now = Date.now()
if (now - lastTime > 200) accumulator = 0
lastTime = now
accumulator += e.deltaX
const threshold = scrollThresholdRef.current
const shouldJump =
(accumulator > threshold || accumulator < -threshold) &&
now - lastJump > 150
if (shouldJump) {
const dir = accumulator > 0 ? 'right' : 'left'
jumpToIndex(Math.round(scrollX.get()) + (dir === 'right' ? 1 : -1), Math.abs(e.deltaX), dir)
accumulator = 0
lastJump = now
}
}
container.addEventListener('wheel', handleWheel, { passive: false })
return () => container.removeEventListener('wheel', handleWheel)
}, [jumpToIndex, scrollX])
const handleCardClick = useCallback(
(item: CoverFlowItem, index: number) => {
if (index === activeIndexRef.current) {
onItemClickRef.current?.(item, index)
} else if (enableClickToSnapRef.current) {
jumpToIndex(index)
}
},
[jumpToIndex],
)
const onDragStart = useCallback(() => setIsDragging(true), [])
const onDrag = useCallback(
(_: unknown, info: PanInfo) => {
scrollX.set(scrollX.get() - info.delta.x / (effectiveCenterGap * 0.8))
},
[effectiveCenterGap, scrollX],
)
const onDragEnd = useCallback(
(_: unknown, info: PanInfo) => {
setIsDragging(false)
const projected = scrollX.get() - info.velocity.x * 0.002
const clamped = clampIndex(Math.round(projected), items.length)
const prev = activeIndexRef.current
const dir: Direction = clamped >= prev ? 'right' : 'left'
setActiveIndex(clamped)
scrollX.set(clamped)
if (clamped !== prev) tick(dir, Math.abs(info.velocity.x))
},
[items.length, scrollX, tick],
)
const onKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'ArrowLeft') { e.preventDefault(); jumpToIndex(activeIndexRef.current - 1, 120, 'left') }
if (e.key === 'ArrowRight') { e.preventDefault(); jumpToIndex(activeIndexRef.current + 1, 120, 'right') }
},
[jumpToIndex],
)
if (items.length === 0) return null
return (
<>
{reflectionFilterId && (
<svg aria-hidden="true" focusable="false" style={{ position: 'absolute', width: 0, height: 0, overflow: 'hidden' }}>
<defs>
<filter id={reflectionFilterId} x="-3%" y="-3%" width="106%" height="106%" colorInterpolationFilters="sRGB">
<feTurbulence type="fractalNoise" baseFrequency="0.018 0.065" numOctaves="3" seed="8" result="noise" />
<feDisplacementMap in="SourceGraphic" in2="noise" scale="5" xChannelSelector="R" yChannelSelector="G" result="displaced" />
<feGaussianBlur in="displaced" stdDeviation="0.4 1.8" />
</filter>
</defs>
</svg>
)}
<motion.div
ref={containerRef}
className={`group/cf relative w-full h-full flex flex-col justify-center items-center overflow-hidden bg-transparent focus:outline-none touch-pan-y ${
isDragging ? 'is-dragging cursor-grabbing' : 'cursor-grab'
} ${className ?? ''}`}
style={{ perspective: 1000 }}
role="region"
aria-label="Cover Flow"
tabIndex={0}
onKeyDown={onKeyDown}
drag="x"
dragConstraints={{ left: 0, right: 0 }}
dragElastic={0}
dragMomentum={false}
onDragStart={onDragStart}
onDrag={onDrag}
onDragEnd={onDragEnd}
>
<div
className="relative w-full h-full flex items-center justify-center pointer-events-none"
style={{ transformStyle: 'preserve-3d' }}
>
{items.map((item, index) => (
<CoverFlowItemCard
key={item.id}
item={item}
index={index}
scrollX={effectiveScrollX}
width={effectiveWidth}
height={effectiveHeight}
stackSpacing={effectiveStackSpacing}
centerGap={effectiveCenterGap}
rotation={rotation}
isActive={index === activeIndex}
showReflection={showReflection}
reflectionFilterId={reflectionFilterId}
enableClickToSnap={enableClickToSnap}
reduceMotion={prefersReducedMotion ?? false}
renderImage={renderImage}
onCardClick={handleCardClick}
/>
))}
</div>
<div className="absolute bottom-8 left-0 right-0 flex flex-col items-center justify-center pointer-events-none z-40">
<AnimatePresence mode="wait">
<motion.div
key={activeIndex}
initial={{ opacity: 0, y: prefersReducedMotion ? 0 : 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: prefersReducedMotion ? 0 : -6 }}
transition={{ duration: prefersReducedMotion ? 0 : 0.25, ease: 'easeOut' }}
className="text-center"
>
<h3 className="text-2xl font-semibold text-foreground tracking-tight drop-shadow-md">
{items[activeIndex]?.title}
</h3>
{items[activeIndex]?.subtitle && (
<p className="text-foreground/60 text-sm mt-1 font-medium tracking-wide">
{items[activeIndex]?.subtitle}
</p>
)}
</motion.div>
</AnimatePresence>
</div>
</motion.div>
</>
)
}
interface CardProps {
item: CoverFlowItem
index: number
scrollX: MotionValue<number>
width: number
height: number
stackSpacing: number
centerGap: number
rotation: number
isActive: boolean
showReflection: boolean
reflectionFilterId?: string
enableClickToSnap: boolean
reduceMotion: boolean
renderImage?: (props: RenderImageProps) => ReactNode
onCardClick: (item: CoverFlowItem, index: number) => void
}
const CoverFlowItemCard = memo(function CoverFlowItemCard({
item,
index,
scrollX,
width,
height,
stackSpacing,
centerGap,
rotation,
isActive,
showReflection,
reflectionFilterId,
enableClickToSnap,
reduceMotion,
renderImage,
onCardClick,
}: CardProps) {
const rotateY = useTransform(scrollX, (value) => {
if (reduceMotion) return 0
const pos = index - value
const absPos = Math.abs(pos)
return absPos < 0.5 ? -pos * (rotation * 2) : pos < 0 ? rotation : -rotation
})
const x = useTransform(scrollX, (value) => {
const pos = index - value
const absPos = Math.abs(pos)
if (absPos < 1) return pos * centerGap
return pos < 0
? -centerGap - (absPos - 1) * stackSpacing
: centerGap + (absPos - 1) * stackSpacing
})
const z = useTransform(scrollX, (value) => {
if (reduceMotion) return 0
const absPos = Math.abs(index - value)
return absPos > 0.5 ? -200 : absPos * -400
})
const zIndex = useTransform(scrollX, (value) => 1000 - Math.abs(index - value) * 10)
const filterStyle = useTransform(
scrollX,
(value) => `brightness(${Math.abs(index - value) < 0.5 ? 1 : 0.5})`,
)
const imageRenderer = renderImage ?? defaultRenderImage
const cursorClass = isActive || enableClickToSnap ? 'cursor-pointer' : 'cursor-grab'
return (
<motion.div
className={`absolute top-1/2 left-1/2 preserve-3d will-change-transform group-[.is-dragging]/cf:!cursor-grabbing ${cursorClass}`}
style={{
width,
height,
marginTop: -height / 2,
marginLeft: -width / 2,
x,
z,
rotateY,
zIndex,
filter: filterStyle,
pointerEvents: 'auto',
}}
onClick={() => onCardClick(item, index)}
>
<div className="relative w-full h-full rounded-xl shadow-2xl bg-black">
<div className="absolute inset-0 rounded-xl border border-white/10 z-20 pointer-events-none" />
<div className="relative w-full h-full overflow-hidden rounded-xl">
{imageRenderer({
src: item.image,
alt: item.title,
width,
height,
className: 'object-cover select-none pointer-events-none w-full h-full',
draggable: false,
sizes: `${width}px`,
priority: isActive,
loading: isActive ? 'eager' : 'lazy',
})}
<div className="absolute inset-0 bg-linear-to-tr from-white/10 to-transparent opacity-0 dark:opacity-20 pointer-events-none z-10" />
</div>
</div>
{showReflection && (
<div
aria-hidden="true"
className="absolute left-0 pointer-events-none overflow-hidden"
style={{
top: '100%',
width,
height: height * 0.42,
marginTop: 1,
transformOrigin: 'top center',
transform: 'rotateX(12deg) translateZ(0)',
willChange: 'transform',
}}
>
<div
style={{
width: '100%',
height: '100%',
transform: 'scaleY(-1)',
filter: reflectionFilterId ? `url(#${reflectionFilterId})` : undefined,
mixBlendMode: reflectionFilterId ? 'screen' : undefined,
opacity: reflectionFilterId ? 0.55 : 0.4,
}}
>
<div className={`relative w-full h-full rounded-xl bg-black ${reflectionFilterId ? 'shadow-2xl' : ''}`}>
<div className="absolute inset-0 rounded-xl border border-white/10 z-20 pointer-events-none" />
<div className="relative w-full h-full overflow-hidden rounded-xl">
{imageRenderer({
src: item.image,
alt: '',
width,
height,
className: 'object-cover w-full h-full',
draggable: false,
sizes: `${width}px`,
loading: 'lazy',
})}
</div>
</div>
</div>
<div
className="absolute inset-0 pointer-events-none"
style={{
background: 'linear-gradient(to top, hsl(var(--background)) 0%, hsl(var(--background) / 0.7) 40%, transparent 100%)',
}}
/>
</div>
)}
</motion.div>
)
})
Interactive Example











Giyu Tomioka
Presets
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| items | CoverFlowItem[] | - | Array of items to display. |
| itemWidth | number | 400 | Width of each card in pixels. |
| itemHeight | number | 400 | Height of each card in pixels. |
| stackSpacing | number | 100 | Spacing between stacked cards. |
| centerGap | number | 250 | Gap between the center card and the stack. |
| rotation | number | 50 | Rotation angle (in degrees) for stacked cards. |
| initialIndex | number | 0 | Index of the initially selected item. |
| enableReflection | boolean | false | Enable or disable reflection effect. |
| enableClickToSnap | boolean | true | Enable or disable clicking on items to snap them to the center. |
| enableScroll | boolean | true | Enable or disable horizontal wheel scroll snapping. |
| scrollThreshold | number | 100 | Wheel delta threshold required before snapping to next card. |
| enableAudio | boolean | false | Enable procedural spatial audio — a crisp stereo tick plays on every index change, panned and pitched by direction and velocity. |
| reduceMotion | boolean | - | Pass true to disable all 3D transforms and animations. Pass false to always enable them. Omit to let the user's OS accessibility setting decide. |
| className | string | - | Additional class names applied to the root container. |
| onItemClick | (item, index) => void | - | Callback fired when the active center card is clicked. |
| onIndexChange | (index) => void | - | Callback fired whenever the active index changes. |
| renderImage | (props: RenderImageProps) => ReactNode | - | Custom image renderer — use this to pass a Next.js <Image> or any other component in place of the default <img>. |