Sticky Stack
Layout

Sticky Stack

Scroll-stacking cards built on pure CSS position: sticky, with a spring-smoothed scroll-linked scale and fade as each card is covered by the next.

Install

npx shadcn@latest add @paragon/sticky-stack

sticky-stack.tsx

"use client";

import * as React from "react";
import {
  motion,
  useReducedMotion,
  useScroll,
  useSpring,
  useTransform,
  type MotionValue,
} from "motion/react";
import { cn } from "@/lib/utils";

interface StickyStackContextValue {
  progress: MotionValue<number>;
  total: number;
}

const StickyStackContext = React.createContext<StickyStackContextValue | null>(
  null,
);

export interface StickyStackProps extends React.ComponentProps<"div"> {
  /** Sticky offset from the scroll edge, px. */
  topOffset?: number;
  /** Extra offset added per card so stacked headers peek out, px. */
  peek?: number;
  /**
   * The scrollable ancestor, when the stack lives inside an
   * `overflow-y-auto` container instead of the page.
   */
  scrollContainerRef?: React.RefObject<HTMLElement | null>;
}

/**
 * Scroll-stacking cards. Positioning is pure CSS `position: sticky` with
 * per-card top offsets; the only JS is a scroll-linked scale + brightness (via
 * useScroll, smoothed through a spring so notchy wheel scrolling doesn't step)
 * that settles covered cards back and dims them as the next one slides over.
 * Cards stay fully opaque — depth comes from scale and brightness, never from
 * see-through opacity — so the stack reads solid and layered. Under reduced
 * motion cards still stack; the raw progress is used and the effect is skipped.
 */
export function StickyStack({
  topOffset = 64,
  peek = 12,
  scrollContainerRef,
  className,
  children,
  ...props
}: StickyStackProps) {
  const ref = React.useRef<HTMLDivElement>(null);
  const reducedMotion = useReducedMotion();
  const { scrollYProgress } = useScroll({
    target: ref,
    container: scrollContainerRef,
    offset: ["start start", "end end"],
  });
  // Spring smoothing: glued to the scrubber, but irons out discrete wheel
  // ticks so the scale/dim reads as continuous rather than stepped.
  const smoothed = useSpring(scrollYProgress, {
    stiffness: 260,
    damping: 36,
    restDelta: 0.0005,
  });
  const progress = reducedMotion ? scrollYProgress : smoothed;

  const items = React.Children.toArray(children);
  const total = items.length;

  const context = React.useMemo(
    () => ({ progress, total }),
    [progress, total],
  );

  return (
    <StickyStackContext.Provider value={context}>
      <div
        ref={ref}
        data-slot="sticky-stack"
        className={cn("flex flex-col gap-6", className)}
        style={
          {
            "--sticky-stack-top": `${topOffset}px`,
            "--sticky-stack-peek": `${peek}px`,
          } as React.CSSProperties
        }
        {...props}
      >
        {items.map((child, index) => (
          <StickyStackItem key={index} index={index}>
            {child}
          </StickyStackItem>
        ))}
      </div>
    </StickyStackContext.Provider>
  );
}

function StickyStackItem({
  index,
  children,
}: {
  index: number;
  children: React.ReactNode;
}) {
  const context = React.useContext(StickyStackContext);
  if (!context) throw new Error("StickyStackItem must be inside StickyStack");
  const { progress, total } = context;
  const reducedMotion = useReducedMotion();

  // Card i settles as the scroll moves through segment [i, i+1] of the
  // container; the last card never has anything stacked over it.
  const isLast = index === total - 1;
  const still = isLast || reducedMotion;
  const start = (index + 0.2) / total;
  const end = (index + 1) / total;

  // Depth cues only — the card surface stays fully opaque so you never see the
  // stack behind it. Covered cards recede (scale) and dim (brightness) evenly
  // in both themes.
  const scale = useTransform(progress, [start, end], still ? [1, 1] : [1, 0.92]);
  const brightness = useTransform(
    progress,
    [start, end],
    still ? [1, 1] : [1, 0.86],
  );
  const filter = useTransform(brightness, (b) => `brightness(${b})`);

  return (
    <motion.div
      data-slot="sticky-stack-item"
      style={{
        scale,
        filter,
        top: `calc(var(--sticky-stack-top) + ${index} * var(--sticky-stack-peek))`,
      }}
      className="sticky origin-top will-change-transform"
    >
      {children}
    </motion.div>
  );
}

export interface StickyStackCardProps extends React.ComponentProps<"div"> {}

/** A default card surface for StickyStack children — optional sugar. */
export function StickyStackCard({
  className,
  ...props
}: StickyStackCardProps) {
  return (
    <div
      data-slot="sticky-stack-card"
      className={cn(
        "rounded-xl bg-card p-6 text-card-foreground shadow-overlay",
        className,
      )}
      {...props}
    />
  );
}