A pinned 3D coverflow carousel inside its own smooth-scroll viewport: scrolling glides the cards past the center, where the active card faces forward while its neighbours rotate away and recede in depth.
Preview
Customize
9 propsInstallation
Copies the component source and its CSS Module into your project.
npx motionfoundry add scroll-coverflowWrites components/ui/scroll-coverflow.tsx and components/styles/scroll-coverflow.module.css.
Usage
Updates live as you change the controls — only props that differ from the defaults are included.
1import { ScrollCoverflow } from "@motionfoundry/ui";2 3export function Example() {4 return (5 <ScrollCoverflow />6 );7}Props
Accessibility
The viewport is a focusable role="region" labelled by the title, so keyboard users can Tab to it and scroll with arrow/Page keys/Space (native, unsmoothed) with a 2px :focus-visible outline. Wheel input is smoothed only while the viewport can still move; at the top/bottom edge wheel events are released to the page, and touch and scrollbar dragging stay native. Cards are a list with real text; the active card gets aria-current and its title is announced through a polite live caption. Progress dots are labelled buttons that scroll to each card. Decorative SVG art is aria-hidden. Under prefers-reduced-motion the wheel is not intercepted and cards slide flat without 3D rotation or depth.
Toggle prefers-reduced-motion in your OS or browser devtools (Rendering → Emulate CSS media feature) to verify the reduced-motion state.
Source
1"use client";2 3import * as React from "react";4import styles from "../styles/scroll-coverflow.module.css";5 6interface SmoothScrollState {7 /** Current scrollTop of the viewport in px */8 scroll: number;9 /** Maximum scrollTop in px */10 limit: number;11 /** scroll / limit, 0..1 */12 progress: number;13}14 15interface SmoothScrollOptions {16 lerp: number;17 wheelMultiplier: number;18 smooth: boolean;19 onScroll?: (state: SmoothScrollState) => void;20}21 22const clamp = (v: number, min: number, max: number) => Math.min(max, Math.max(min, v));23 24function usePrefersReducedMotion(): boolean {25 const [reduced, setReduced] = React.useState(false);26 React.useEffect(() => {27 const mq = window.matchMedia("(prefers-reduced-motion: reduce)");28 const update = () => setReduced(mq.matches);29 update();30 mq.addEventListener("change", update);31 return () => mq.removeEventListener("change", update);32 }, []);33 return reduced;34}35 36/** True when a nested scrollable element under the pointer can still scroll in the wheel direction. */37function nestedCanScroll(node: EventTarget | null, root: HTMLElement, dy: number): boolean {38 let el: Element | null = node instanceof Element ? node : null;39 while (el && el !== root) {40 if (el instanceof HTMLElement) {41 if (el.dataset.mfScrollPrevent !== undefined) return true;42 const oy = window.getComputedStyle(el).overflowY;43 if ((oy === "auto" || oy === "scroll") && el.scrollHeight > el.clientHeight + 1) {44 if (dy < 0 ? el.scrollTop > 0 : el.scrollTop + el.clientHeight < el.scrollHeight - 1) return true;45 }46 }47 el = el.parentElement;48 }49 return false;50}51 52/**53 * Self-contained smooth scroller for an overflow container: wheel input sets a target, a rAF54 * loop eases scrollTop toward it (frame-rate independent) and stops when settled. Touch,55 * keyboard and scrollbar stay native; at the edges wheel events are released to the page.56 */57function useSmoothScroll(58 containerRef: React.RefObject<HTMLElement | null>,59 { lerp, wheelMultiplier, smooth, onScroll }: SmoothScrollOptions,60) {61 const callbackRef = React.useRef(onScroll);62 React.useEffect(() => {63 callbackRef.current = onScroll;64 }, [onScroll]);65 66 React.useEffect(() => {67 const el = containerRef.current;68 if (!el) return;69 const ease = clamp(Number.isFinite(lerp) ? lerp : 0.1, 0.01, 1);70 const multiplier = Number.isFinite(wheelMultiplier) ? wheelMultiplier : 1;71 let target = el.scrollTop;72 let current = target;73 let raf = 0;74 let lastTime = 0;75 const getLimit = () => Math.max(0, el.scrollHeight - el.clientHeight);76 const emit = () => {77 const limit = getLimit();78 const scroll = clamp(el.scrollTop, 0, limit);79 callbackRef.current?.({ scroll, limit, progress: limit > 0 ? scroll / limit : 0 });80 };81 const stop = () => {82 raf = 0;83 lastTime = 0;84 };85 const tick = (time: number) => {86 const frames = (lastTime ? clamp(time - lastTime, 1, 64) : 16.667) / 16.667;87 lastTime = time;88 target = clamp(target, 0, getLimit());89 if (Math.abs(el.scrollTop - current) > 2) {90 // Scrollbar drag, keyboard or touch moved the viewport: hand control back.91 current = target = el.scrollTop;92 stop();93 emit();94 return;95 }96 current += (target - current) * (1 - Math.pow(1 - ease, frames));97 if (Math.abs(target - current) < 0.35) current = target;98 el.scrollTop = current;99 emit();100 if (current === target) stop();101 else raf = requestAnimationFrame(tick);102 };103 const onWheel = (event: WheelEvent) => {104 if (event.ctrlKey || event.defaultPrevented || Math.abs(event.deltaX) > Math.abs(event.deltaY)) return;105 let dy = event.deltaY;106 if (event.deltaMode === 1) dy *= 16;107 else if (event.deltaMode === 2) dy *= el.clientHeight;108 dy *= multiplier;109 if (dy === 0 || nestedCanScroll(event.target, el, dy)) return;110 const limit = getLimit();111 if (!raf) current = target = el.scrollTop;112 // Release to native scrolling at the edges so the page is never trapped.113 if ((dy < 0 && target <= 0) || (dy > 0 && target >= limit - 0.5)) return;114 event.preventDefault();115 target = clamp(target + dy, 0, limit);116 if (!raf) raf = requestAnimationFrame(tick);117 };118 const onNativeScroll = () => {119 if (raf) return;120 current = target = el.scrollTop;121 emit();122 };123 if (smooth) el.addEventListener("wheel", onWheel, { passive: false });124 el.addEventListener("scroll", onNativeScroll, { passive: true });125 const ro = new ResizeObserver(emit);126 ro.observe(el);127 if (el.firstElementChild) ro.observe(el.firstElementChild);128 emit();129 return () => {130 if (smooth) el.removeEventListener("wheel", onWheel);131 el.removeEventListener("scroll", onNativeScroll);132 ro.disconnect();133 if (raf) cancelAnimationFrame(raf);134 };135 }, [containerRef, lerp, wheelMultiplier, smooth]);136}137 138export interface ScrollCoverflowProps {139 /** Card titles, one card per item */140 items?: string[];141 /** Heading shown above the carousel (also the region label) */142 title?: string;143 /** Rotation in degrees of cards beside the center card */144 angle?: number;145 /** Horizontal distance in px between neighbouring cards */146 spacing?: number;147 /** How far in px side cards are pushed back in depth */148 depth?: number;149 /** Accent color for kicker, caption and progress dots */150 accentColor?: string;151 /** Viewport height in px */152 height?: number;153 /** Viewport corner radius in px */154 borderRadius?: number;155 /** Interpolation factor per 60fps frame (0.05 = floaty, 0.3 = snappy) */156 lerp?: number;157 /** Multiplier applied to wheel / trackpad deltas */158 wheelMultiplier?: number;159 /** Enable smooth wheel scrolling (native scrolling when false or under reduced motion) */160 smooth?: boolean;161 /** Optional className merged onto the root */162 className?: string;163 /** Inline styles merged onto the root */164 style?: React.CSSProperties;165}166 167const DEFAULT_ITEMS = ["Midnight Drive", "Neon Bloom", "Low Tide", "Paper Moons", "Afterglow", "Static Hearts", "Golden Hour"];168const META = ["Synthwave", "Dream pop", "Ambient", "Indie folk", "Chillwave", "Electronica", "Soul"];169const PALETTE = ["#f472b6", "#a78bfa", "#60a5fa", "#22d3ee", "#34d399", "#fbbf24", "#fb7185"];170 171export function ScrollCoverflow({172 items = DEFAULT_ITEMS,173 title = "Scroll through the collection",174 angle = 55,175 spacing = 120,176 depth = 180,177 accentColor = "#a78bfa",178 height = 380,179 borderRadius = 20,180 lerp = 0.1,181 wheelMultiplier = 1,182 smooth = true,183 className = "",184 style,185}: ScrollCoverflowProps) {186 const reduced = usePrefersReducedMotion();187 const viewportRef = React.useRef<HTMLDivElement>(null);188 const cardRefs = React.useRef<(HTMLLIElement | null)[]>([]);189 const [active, setActive] = React.useState(0);190 const total = items.length;191 const perCard = Math.round(height * 0.5);192 193 const handleScroll = React.useCallback(194 ({ progress }: SmoothScrollState) => {195 const t = progress * Math.max(0, total - 1);196 for (let i = 0; i < total; i++) {197 const card = cardRefs.current[i];198 if (!card) continue;199 const d = i - t;200 const a = Math.abs(d);201 const s = Math.sign(d);202 const near = Math.min(a, 1);203 const far = Math.max(0, a - 1);204 const x = s * (near * spacing * 1.25 + far * spacing * 0.62);205 card.style.setProperty("--mf-scf-x", `${x.toFixed(2)}px`);206 card.style.setProperty("--mf-scf-z", `${(-(near * depth + far * depth * 0.22)).toFixed(2)}px`);207 card.style.setProperty("--mf-scf-ry", `${(-s * near * angle).toFixed(2)}deg`);208 card.style.setProperty("--mf-scf-b", (1 - near * 0.3 - far * 0.08).toFixed(3));209 card.style.setProperty("--mf-scf-o", clamp(1 - Math.max(0, a - 2.6) * 0.8, 0, 1).toFixed(3));210 card.style.zIndex = String(1000 - Math.round(a * 10));211 }212 setActive(clamp(Math.round(t), 0, Math.max(0, total - 1)));213 },214 [total, spacing, depth, angle],215 );216 217 useSmoothScroll(viewportRef, { lerp, wheelMultiplier, smooth: smooth && !reduced, onScroll: handleScroll });218 219 const goTo = (i: number) => {220 const el = viewportRef.current;221 if (!el || total < 2) return;222 const limit = el.scrollHeight - el.clientHeight;223 el.scrollTo({ top: (i / (total - 1)) * limit, behavior: reduced ? "auto" : "smooth" });224 };225 226 const cardH = Math.round(height * 0.5);227 const vars = {228 "--mf-scf-h": `${height}px`,229 "--mf-scf-r": `${borderRadius}px`,230 "--mf-scf-accent": accentColor,231 "--mf-scf-card-h": `${cardH}px`,232 "--mf-scf-card-w": `${Math.round(cardH * 0.8)}px`,233 ...style,234 } as React.CSSProperties;235 236 return (237 <div style={vars} className={`${styles.root} ${className}`.trim()}>238 <div ref={viewportRef} className={styles.viewport} tabIndex={0} role="region" aria-label={title}>239 <div className={styles.track} style={{ height: height + Math.max(0, total - 1) * perCard }}>240 <div className={styles.stage}>241 <header className={styles.header}>242 <span className={styles.kicker}>Coverflow</span>243 <h3 className={styles.title}>{title}</h3>244 </header>245 <ul className={styles.ring}>246 {items.map((item, i) => {247 const c1 = PALETTE[i % PALETTE.length];248 const c2 = PALETTE[(i + 2) % PALETTE.length];249 return (250 <li251 key={`${item}-${i}`}252 ref={(node) => {253 cardRefs.current[i] = node;254 }}255 className={styles.card}256 aria-current={i === active ? "true" : undefined}257 style={{ "--mf-scf-c1": c1, "--mf-scf-c2": c2 } as React.CSSProperties}258 >259 <svg aria-hidden="true" className={styles.art} viewBox="0 0 100 100" preserveAspectRatio="none">260 <circle cx={30 + ((i * 23) % 45)} cy={32 + ((i * 17) % 30)} r={22 + (i % 3) * 6} fill="rgba(255,255,255,0.22)" />261 <circle cx={70 - ((i * 13) % 40)} cy={70} r={14 + (i % 4) * 4} fill="rgba(0,0,0,0.18)" />262 <path d={`M0 ${78 - (i % 3) * 6} Q 50 ${58 + (i % 4) * 5} 100 ${74 - (i % 2) * 8} L100 100 L0 100 Z`} fill="rgba(0,0,0,0.22)" />263 </svg>264 <span className={styles.num}>{String(i + 1).padStart(2, "0")}</span>265 <span className={styles.cardText}>266 <span className={styles.cardTitle}>{item}</span>267 <span className={styles.cardMeta}>{META[i % META.length]}</span>268 </span>269 </li>270 );271 })}272 </ul>273 <div className={styles.footer}>274 <span className={styles.caption} aria-live="polite">275 {items[active]}276 <span className={styles.count}>{` ${String(active + 1).padStart(2, "0")} / ${String(total).padStart(2, "0")}`}</span>277 </span>278 <div className={styles.dots}>279 {items.map((item, i) => (280 <button281 key={`${item}-dot-${i}`}282 type="button"283 className={styles.dot}284 data-active={i === active ? "" : undefined}285 aria-label={`Show ${item}`}286 onClick={() => goTo(i)}287 />288 ))}289 </div>290 </div>291 </div>292 </div>293 </div>294 </div>295 );296}