ScrollScroll Animationsno dependencies

StickyStackCards

Cards pin one after another inside a smooth-scroll viewport, each settling a few pixels below the last, while the cards underneath scale down and dim in proportion to how many cards cover them.

Preview

Sticky Stack

Stacked, not stashed

Scroll and each card settles on top of the last.

  1. 01Stage 1 of 5
    Research
  2. 02Stage 2 of 5
    Wireframes
  3. 03Stage 3 of 5
    Visual design
  4. 04Stage 4 of 5
    Motion pass
  5. 05Stage 5 of 5
    Launch

That is the whole stack.

Customize

8 props
14
0.05
0.1
1

Installation

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

npx motionfoundry add sticky-stack-cards

Writes components/ui/sticky-stack-cards.tsx and components/styles/sticky-stack-cards.module.css.

Usage

Updates live as you change the controls — only props that differ from the defaults are included.

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

Props

PropTypeDefault
items

Card titles, one stacked card per item.

string[]['Research', 'Wireframes', 'Visual design', 'Motion pass', 'Launch']
title

Heading shown before the stack.

string'Stacked, not stashed'
offset

Vertical step in px between stacked cards.

number14
scaleStep

Scale reduction applied per card covering an earlier card.

number0.05
accentColor

Accent color for card glow, chip and chart bars.

string'#f472b6'
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" with an aria-label, so keyboard users can Tab to it and scroll with arrows/Page keys/Space (native, unsmoothed) and see 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 so the page keeps scrolling. Touch and scrollbar dragging stay native. Under prefers-reduced-motion (or smooth=false) the wheel is not intercepted at all. Cards are an ordered list with real text titles; glow and chart decorations are aria-hidden. Under prefers-reduced-motion the stack still pins but covered cards are not scaled.

Toggle prefers-reduced-motion in your OS or browser devtools (Rendering → Emulate CSS media feature) to verify the reduced-motion state.

Source

components/ui/sticky-stack-cards.tsx
1"use client";2 3import * as React from "react";4import styles from "../styles/sticky-stack-cards.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  /** Signed scroll velocity in px per 60fps frame */14  velocity: number;15}16 17interface SmoothScrollOptions {18  lerp: number;19  wheelMultiplier: number;20  smooth: boolean;21  onScroll?: (state: SmoothScrollState) => void;22}23 24const clamp = (v: number, min: number, max: number) => Math.min(max, Math.max(min, v));25 26function usePrefersReducedMotion(): boolean {27  const [reduced, setReduced] = React.useState(false);28  React.useEffect(() => {29    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");30    const update = () => setReduced(mq.matches);31    update();32    mq.addEventListener("change", update);33    return () => mq.removeEventListener("change", update);34  }, []);35  return reduced;36}37 38/** True when a nested scrollable element under the pointer can still scroll in the wheel direction. */39function nestedCanScroll(node: EventTarget | null, root: HTMLElement, dy: number): boolean {40  let el: Element | null = node instanceof Element ? node : null;41  while (el && el !== root) {42    if (el instanceof HTMLElement) {43      if (el.dataset.mfScrollPrevent !== undefined) return true;44      const overflowY = window.getComputedStyle(el).overflowY;45      if ((overflowY === "auto" || overflowY === "scroll") && el.scrollHeight > el.clientHeight + 1) {46        const canMove = dy < 0 ? el.scrollTop > 0 : el.scrollTop + el.clientHeight < el.scrollHeight - 1;47        if (canMove) return true;48      }49    }50    el = el.parentElement;51  }52  return false;53}54 55/**56 * Self-contained smooth scroller for an overflow container. Wheel input updates a target57 * position; a rAF loop eases the real scrollTop toward it (frame-rate independent lerp) and58 * stops once settled. Touch, keyboard and scrollbar drags stay native. At the top/bottom edge59 * wheel events are released so the page keeps scrolling.60 */61function useSmoothScroll(62  containerRef: React.RefObject<HTMLElement | null>,63  { lerp, wheelMultiplier, smooth, onScroll }: SmoothScrollOptions,64): React.RefObject<SmoothScrollState> {65  const stateRef = React.useRef<SmoothScrollState>({ scroll: 0, limit: 0, progress: 0, velocity: 0 });66  const callbackRef = React.useRef(onScroll);67  React.useEffect(() => {68    callbackRef.current = onScroll;69  }, [onScroll]);70 71  React.useEffect(() => {72    const el = containerRef.current;73    if (!el) return;74    const ease = clamp(Number.isFinite(lerp) ? lerp : 0.1, 0.01, 1);75    const multiplier = Number.isFinite(wheelMultiplier) ? wheelMultiplier : 1;76    let target = el.scrollTop;77    let current = target;78    let raf = 0;79    let lastTime = 0;80    let lastNative = el.scrollTop;81    let lastNativeTime = 0;82    let settleTimer: ReturnType<typeof setTimeout> | undefined;83 84    const getLimit = () => Math.max(0, el.scrollHeight - el.clientHeight);85 86    const emit = (velocity: number) => {87      const limit = getLimit();88      const scroll = clamp(el.scrollTop, 0, limit);89      lastNative = el.scrollTop;90      const s = stateRef.current;91      s.scroll = scroll;92      s.limit = limit;93      s.progress = limit > 0 ? scroll / limit : 0;94      s.velocity = velocity;95      callbackRef.current?.(s);96    };97 98    const tick = (time: number) => {99      const dt = lastTime ? Math.min(64, Math.max(1, time - lastTime)) : 16.667;100      lastTime = time;101      const limit = getLimit();102      target = clamp(target, 0, limit);103      if (Math.abs(el.scrollTop - current) > 2) {104        // Something else moved the viewport (scrollbar drag, keyboard, touch): hand control back.105        current = target = el.scrollTop;106        raf = 0;107        lastTime = 0;108        emit(0);109        return;110      }111      const prev = current;112      const frames = dt / 16.667;113      current += (target - current) * (1 - Math.pow(1 - ease, frames));114      if (Math.abs(target - current) < 0.35) current = target;115      el.scrollTop = current;116      emit((current - prev) / frames);117      if (current === target) {118        raf = 0;119        lastTime = 0;120        emit(0);121        return;122      }123      raf = requestAnimationFrame(tick);124    };125 126    const onWheel = (event: WheelEvent) => {127      if (event.ctrlKey || event.defaultPrevented) return;128      if (Math.abs(event.deltaX) > Math.abs(event.deltaY)) return;129      let dy = event.deltaY;130      if (event.deltaMode === 1) dy *= 16;131      else if (event.deltaMode === 2) dy *= el.clientHeight;132      dy *= multiplier;133      if (dy === 0 || nestedCanScroll(event.target, el, dy)) return;134      const limit = getLimit();135      if (!raf) current = target = el.scrollTop;136      // Release to native scrolling at the edges so the page is never trapped.137      if ((dy < 0 && target <= 0) || (dy > 0 && target >= limit - 0.5)) return;138      event.preventDefault();139      target = clamp(target + dy, 0, limit);140      if (!raf) {141        lastTime = 0;142        raf = requestAnimationFrame(tick);143      }144    };145 146    const onNativeScroll = () => {147      if (raf) return;148      const top = el.scrollTop;149      const delta = top - lastNative;150      if (delta === 0) return;151      const now = performance.now();152      const dt = lastNativeTime ? clamp(now - lastNativeTime, 8, 100) : 16.667;153      lastNativeTime = now;154      current = target = top;155      emit(delta / (dt / 16.667));156      if (settleTimer) clearTimeout(settleTimer);157      settleTimer = setTimeout(() => {158        lastNativeTime = 0;159        emit(0);160      }, 90);161    };162 163    if (smooth) el.addEventListener("wheel", onWheel, { passive: false });164    el.addEventListener("scroll", onNativeScroll, { passive: true });165    const ro = new ResizeObserver(() => emit(stateRef.current.velocity));166    ro.observe(el);167    if (el.firstElementChild) ro.observe(el.firstElementChild);168    emit(0);169 170    return () => {171      if (smooth) el.removeEventListener("wheel", onWheel);172      el.removeEventListener("scroll", onNativeScroll);173      ro.disconnect();174      if (raf) cancelAnimationFrame(raf);175      if (settleTimer) clearTimeout(settleTimer);176    };177  }, [containerRef, lerp, wheelMultiplier, smooth]);178 179  return stateRef;180}181 182export interface StickyStackCardsProps {183  /** Card titles, one stacked card per item */184  items?: string[];185  /** Heading shown before the stack */186  title?: string;187  /** Vertical step in px between stacked cards */188  offset?: number;189  /** Scale reduction applied per card covering an earlier card */190  scaleStep?: number;191  /** Accent color for card glow and charts */192  accentColor?: string;193  /** Viewport height in px */194  height?: number;195  /** Viewport corner radius in px */196  borderRadius?: number;197  /** Interpolation factor per 60fps frame (0.05 = floaty, 0.3 = snappy) */198  lerp?: number;199  /** Multiplier applied to wheel / trackpad deltas */200  wheelMultiplier?: number;201  /** Enable smooth wheel scrolling (native scrolling when false or under reduced motion) */202  smooth?: boolean;203  /** Optional className merged onto the root */204  className?: string;205  /** Inline styles merged onto the root */206  style?: React.CSSProperties;207}208 209const DEFAULT_ITEMS = ["Research", "Wireframes", "Visual design", "Motion pass", "Launch"];210 211const rand = (n: number) => {212  const x = Math.sin(n * 9301 + 49297) * 233280;213  return x - Math.floor(x);214};215 216const STICKY_TOP = 22;217 218export function StickyStackCards({219  items = DEFAULT_ITEMS,220  title = "Stacked, not stashed",221  offset = 14,222  scaleStep = 0.05,223  accentColor = "#f472b6",224  height = 380,225  borderRadius = 20,226  lerp = 0.1,227  wheelMultiplier = 1,228  smooth = true,229  className = "",230  style,231}: StickyStackCardsProps) {232  const reduced = usePrefersReducedMotion();233  const viewportRef = React.useRef<HTMLDivElement>(null);234  const listRef = React.useRef<HTMLOListElement>(null);235  const cardRefs = React.useRef<(HTMLLIElement | null)[]>([]);236  const listTopRef = React.useRef(0);237  const lastScrollRef = React.useRef(0);238 239  const total = items.length;240  const cardHeight = Math.round(height * 0.6);241  const spacing = Math.round(height * 0.42);242  const slot = cardHeight + spacing;243  const distance = Math.max(1, slot - offset);244 245  const apply = React.useCallback(() => {246    const scroll = lastScrollRef.current;247    for (let i = 0; i < total; i++) {248      const card = cardRefs.current[i];249      if (!card) continue;250      const stickAt = listTopRef.current + i * slot - (STICKY_TOP + i * offset);251      const covered = clamp((scroll - stickAt) / distance, 0, total - 1 - i);252      card.style.setProperty("--mf-ssc-c", covered.toFixed(4));253    }254  }, [total, slot, offset, distance]);255 256  const handleScroll = React.useCallback(257    (state: SmoothScrollState) => {258      lastScrollRef.current = state.scroll;259      apply();260    },261    [apply],262  );263 264  useSmoothScroll(viewportRef, { lerp, wheelMultiplier, smooth: smooth && !reduced, onScroll: handleScroll });265 266  React.useEffect(() => {267    const list = listRef.current;268    if (!list) return;269    const measure = () => {270      listTopRef.current = list.offsetTop;271      apply();272    };273    measure();274    const ro = new ResizeObserver(measure);275    ro.observe(list);276    return () => ro.disconnect();277  }, [apply]);278 279  const vars = {280    "--mf-ssc-h": `${height}px`,281    "--mf-ssc-r": `${borderRadius}px`,282    "--mf-ssc-accent": accentColor,283    "--mf-ssc-step": scaleStep,284    "--mf-ssc-card-h": `${cardHeight}px`,285    "--mf-ssc-spacing": `${spacing}px`,286    "--mf-ssc-top": `${STICKY_TOP}px`,287    "--mf-ssc-offset": `${offset}px`,288    ...style,289  } as React.CSSProperties;290 291  return (292    <div style={vars} className={`${styles.root} ${className}`.trim()}>293      <div ref={viewportRef} className={styles.viewport} tabIndex={0} role="region" aria-label={title}>294        <div className={styles.content}>295          <header className={styles.intro}>296            <span className={styles.kicker}>Sticky Stack</span>297            <h3 className={styles.title}>{title}</h3>298            <p className={styles.subtitle}>Scroll and each card settles on top of the last.</p>299          </header>300          <ol ref={listRef} className={styles.list}>301            {items.map((item, i) => (302              <li303                key={`${item}-${i}`}304                ref={(node) => {305                  cardRefs.current[i] = node;306                }}307                className={styles.card}308                style={{ "--mf-ssc-i": i } as React.CSSProperties}309              >310                <span aria-hidden="true" className={styles.glow} />311                <div className={styles.cardHead}>312                  <span className={styles.index}>{String(i + 1).padStart(2, "0")}</span>313                  <span className={styles.chip}>{`Stage ${i + 1} of ${total}`}</span>314                </div>315                <span className={styles.cardTitle}>{item}</span>316                <span aria-hidden="true" className={styles.chart}>317                  {Array.from({ length: 12 }, (_, b) => (318                    <span319                      key={b}320                      className={styles.bar}321                      style={{ height: `${Math.round(22 + rand(i * 31 + b) * 78)}%` }}322                    />323                  ))}324                </span>325              </li>326            ))}327          </ol>328          <p className={styles.outro}>That is the whole stack.</p>329        </div>330      </div>331    </div>332  );333}