Skip to content
Docs
Stacked Sections

Stacked Sections

A vertical deck primitive that pins each child as a sticky pane, leaving a thin strip of the one before visible above — a true stack of cards.

newscrollrequires interaction
Loading...

Installation

CLI

pnpm dlx shadcn@latest add https://animata.design/r/scroll/stacked-sections.json

Manual

Run the following command

It will create a new file called stacked-sections.tsx inside the components/animata/scroll directory.

mkdir -p components/animata/scroll && touch components/animata/scroll/stacked-sections.tsx components/animata/scroll/stacked-sections.css

Paste the code

@layer components {
  [data-stacked-content][data-stacked-covered] {
    transform-origin: 50% 0%;
  }
}
"use client";
 
import * as React from "react";
 
import { cn } from "@/lib/utils";
 
import "./stacked-sections.css";
 
export type StackedSectionsProps = {
  /**
   * One pane per direct child — sticky card > inner content (scale only).
   * Siblings in one deck so every sticky shares the deck scroll range (stacking-cards pattern).
   */
  children?: React.ReactNode;
  /** Scale covered panes while the next card rises into pin. @default true */
  withDramaEffect?: boolean;
  /**
   * Stagger between stacked panes (`padding-top` on each sticky card, 1-based index).
   * Match peek strip height. @default 48
   */
  stackOffset?: number;
  /** Tailwind gap classes between cards (block `gap-*`). Pass `false` to disable. @default `gap-2` */
  paneGap?: false | string;
  /**
   * In-flow spacer height after the last card so the final pane stays pinned through the outro scroll.
   * @default `0px`
   */
  scrollRunway?: string;
  className?: string;
};
 
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
 
function getScrollParent(node: HTMLElement | null): HTMLElement | null {
  let element = node?.parentElement ?? null;
  while (element && element !== document.body && element !== document.documentElement) {
    const overflowY = getComputedStyle(element).overflowY;
    if (overflowY === "auto" || overflowY === "scroll") {
      return element;
    }
    element = element.parentElement;
  }
  return null;
}
 
export default function StackedSections({
  children,
  withDramaEffect = true,
  stackOffset = 48,
  paneGap = "gap-2",
  scrollRunway = "0px",
  className,
}: StackedSectionsProps) {
  const deckRef = React.useRef<HTMLDivElement>(null);
  const cardRefs = React.useRef<(HTMLDivElement | null)[]>([]);
  const contentRefs = React.useRef<(HTMLDivElement | null)[]>([]);
 
  const items = React.Children.toArray(children);
  const total = items.length;
  cardRefs.current.length = total;
  contentRefs.current.length = total;
 
  const scaleAtDepth = React.useCallback(
    (cardIndex: number) => {
      const reverseIndex = total - (cardIndex - 1);
      return 1.1 - 0.1 * reverseIndex;
    },
    [total],
  );
 
  // Scale on `.card__content` only. Each pane freezes once the next card has pinned.
  React.useEffect(() => {
    if (!withDramaEffect || total === 0) {
      return;
    }
    if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
      return;
    }
 
    const deck = deckRef.current;
    if (!deck) {
      return;
    }
 
    const scroller = getScrollParent(deck);
    let frame = 0;
 
    const isNextCardPinned = (cardIndex: number, containerTop: number) => {
      const nextCard = cardRefs.current[cardIndex + 1];
      if (!nextCard) {
        return false;
      }
      return (
        nextCard.getBoundingClientRect().top - containerTop <= (cardIndex + 1) * stackOffset + 1
      );
    };
 
    const update = () => {
      frame = 0;
      const containerTop = scroller ? scroller.getBoundingClientRect().top : 0;
 
      for (let i = 0; i < total; i++) {
        const card = cardRefs.current[i];
        const content = contentRefs.current[i];
        if (!card || !content) {
          continue;
        }
 
        const endScale = scaleAtDepth(i + 1);
        const covered = isNextCardPinned(i, containerTop);
 
        if (covered) {
          content.dataset.stackedCovered = "";
          content.style.transform = `scale(${endScale})`;
          continue;
        }
 
        delete content.dataset.stackedCovered;
 
        const nextCard = cardRefs.current[i + 1];
        if (!nextCard) {
          content.style.transform = "";
          continue;
        }
 
        const pinnedTop = (i + 1) * stackOffset;
        const offset = nextCard.getBoundingClientRect().top - containerTop - pinnedTop;
        const rowH = card.offsetHeight > 0 ? card.offsetHeight : 1;
        const distance = Math.max(rowH - pinnedTop, 1);
        const progress = clamp(1 - offset / distance, 0, 1);
        const scale = 1 + (endScale - 1) * progress;
        content.style.transform = progress <= 0.001 ? "" : `scale(${scale})`;
      }
    };
 
    const onScroll = () => {
      if (!frame) {
        frame = requestAnimationFrame(update);
      }
    };
 
    update();
    const target: Window | HTMLElement = scroller ?? window;
    target.addEventListener("scroll", onScroll, { passive: true });
    window.addEventListener("resize", onScroll);
 
    return () => {
      target.removeEventListener("scroll", onScroll);
      window.removeEventListener("resize", onScroll);
      if (frame) {
        cancelAnimationFrame(frame);
      }
      for (const content of contentRefs.current) {
        if (content) {
          delete content.dataset.stackedCovered;
          content.style.transform = "";
        }
      }
    };
  }, [total, stackOffset, withDramaEffect, scaleAtDepth]);
 
  if (total === 0) {
    return null;
  }
 
  const gapClass = paneGap === false ? undefined : paneGap;
 
  return (
    <>
      <div
        ref={deckRef}
        data-stacked-deck=""
        className={cn("flex w-full flex-col", gapClass, className)}
        style={
          {
            "--numcards": total,
            "--stacked-top-offset": `${stackOffset}px`,
            paddingBottom: `calc(${total} * ${stackOffset}px)`,
          } as React.CSSProperties
        }
      >
        {items.map((child, index) => {
          const key =
            React.isValidElement(child) && child.key != null ? child.key : `pane-${index}`;
          const cardIndex = index + 1;
 
          return (
            <div
              key={key}
              ref={(el) => {
                cardRefs.current[index] = el;
              }}
              data-stacked-card=""
              className="sticky top-0 w-full"
              style={
                {
                  "--index": cardIndex,
                  zIndex: cardIndex,
                  paddingTop: `calc(${cardIndex} * var(--stacked-top-offset))`,
                } as React.CSSProperties
              }
            >
              <div
                ref={(el) => {
                  contentRefs.current[index] = el;
                }}
                data-stacked-content=""
                className="min-h-0 origin-[50%_0%]"
              >
                {child}
              </div>
            </div>
          );
        })}
        {scrollRunway !== "0px" ? (
          <div
            aria-hidden
            className="w-full shrink-0"
            style={{ height: scrollRunway }}
            data-stacked-runway=""
          />
        ) : null}
      </div>
    </>
  );
}

Usage

StackedSections is a thin primitive. Each direct child becomes one card in the deck; palette, copy, and layout are yours.

<StackedSections stackOffset={48}>
  <MyFirstChapter />
  <MySecondChapter />
  <MyThirdChapter />
</StackedSections>

How it works

This mirrors the CSS stacking-cards demo:

[data-stacked-deck]          ← flex column; handoff padding + in-flow scrollRunway spacer
  [data-stacked-card]        ← position: sticky; top: 0; padding-top: index × offset
    [data-stacked-content]   ← scale while the next card rises; frozen once it has pinned
      {your chapter}
  1. Deck (#cards). A single flex column — all cards are siblings so each position: sticky shares the deck as its scroll range (earlier cards stay pinned while later ones stack on top). padding-bottom: numcards × stackOffset handles stack handoff; an in-flow [data-stacked-runway] spacer (height = scrollRunway) extends that range so the last pane does not scroll away early.
  2. Card (.card). position: sticky, top: 0, and padding-top: calc(var(--index) × offset) so each pane pins at the same edge but peeks below the previous (--index is 1-based, like the reference).
  3. Content (.card__content). Transform runs here so scroll height stays honest. While the next card rises into its pin, scale interpolates toward scale(calc(1.1 - 0.1 × reverse-index)). Once the next card has stuck, that pane's scale locks — it does not keep animating when you scroll through runway padding.
  4. Why not deck-level scroll-driven CSS? Tying every card to one view-timeline exit-crossing slice re-animated covered panes after they had already pinned. Per-pane freeze on cover matches the stacking-cards intent more reliably.

When to use

Use this for narrative pages with a handful of distinct chapters — a curated reading list, a multi-act feature story, an editorial release page, an "about" or manifesto split into themes. The deck-stacking gesture turns ordinary scrolling into a reveal where every previous chapter's strip remains visible as the next one pins on top.

When not to use

  • For more than about five panes. A deeper deck makes the lower panes invisible for too long.
  • When the panes aren't visually distinct — if every chapter is the same colour, the stacking gesture has nothing to dramatise.
  • As a primary navigation pattern. Readers can't easily jump between panes; the deck is meant for linear scrolling.

Props

Component props
PropTypeDefaultDescription
childrenReact.ReactNode

One card per direct child. Fragments are flattened.

withDramaEffectbooleantrue

Scale covered panes on the inner content layer while the next card pins. Disable for sticky-only.

stackOffsetnumber48

Pixels per 1-based index for each card's padding-top stagger (peek band). Set to 0 for a flush stack.

paneGapfalse | stringgap-2

Tailwind gap-* classes on the deck flex column. Pass false to disable.

scrollRunwaystring0px

Height of the in-flow runway spacer after the last card. Use in boxed scrollers so the final pane stays pinned — e.g. min(75vh, 32rem) in Storybook.

classNamestring

Forwarded to the deck wrapper.

Accessibility

  • Use real headings (<h2>) inside each pane — the primitive doesn't impose structure.
  • If a pane carries links, render them as <a> elements with palette-tinted focus rings.
  • No scroll hijacking — a passive scroll listener drives the optional cover scale only.
  • prefers-reduced-motion disables the scale animation; cards still pin.