ScrollScroll Animationsno dependencies

ScrollCoverflow

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

Coverflow

Scroll through the collection

  • 01Midnight DriveSynthwave
  • 02Neon BloomDream pop
  • 03Low TideAmbient
  • 04Paper MoonsIndie folk
  • 05AfterglowChillwave
  • 06Static HeartsElectronica
  • 07Golden HourSoul

Customize

9 props
55
120
180
0.1
1

Installation

Copies the component source and its CSS Module into your project.

npx motionfoundry add scroll-coverflow

Writes 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.

example.tsx
1import { ScrollCoverflow } from "@motionfoundry/ui";2 3export function Example() {4  return (5    <ScrollCoverflow />6  );7}

Props

PropTypeDefault
items

Card titles, one gradient card per item.

string[]['Midnight Drive', 'Neon Bloom', 'Low Tide', 'Paper Moons', 'Afterglow', 'Static Hearts', 'Golden Hour']
title

Heading above the carousel; also used as the scroll region's aria-label.

string'Scroll through the collection'
angle

Rotation in degrees applied to cards beside the center card.

number55
spacing

Horizontal distance in px between neighbouring cards.

number120
depth

How far in px side cards are pushed back along the Z axis.

number180
accentColor

Accent color for the kicker, floor glow and active progress dot.

string'#a78bfa'
height

Height of the component's own scroll viewport in px (width is always 100%).

number380
borderRadius

Corner radius of the viewport in px.

number20
lerp

Interpolation factor per 60fps frame for wheel smoothing (0.05 floaty, 0.3 snappy).

number0.1
wheelMultiplier

Multiplier applied to wheel and trackpad deltas.

number1
smooth

Enable smooth wheel scrolling; false (or prefers-reduced-motion) uses native scrolling.

booleantrue
className

Additional class merged onto the root element.

string''
style

Inline styles merged onto the root element.

React.CSSPropertiesundefined

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

components/ui/scroll-coverflow.tsx
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}