Layout

Sticky Stack

Scroll-stacking cards built on pure CSS position: sticky, with a tiny 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,
  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/fade (via
 * useScroll) that settles covered cards to 0.97 as the next one slides
 * over. Under reduced motion cards still stack — they just skip the scale.
 */
export function StickyStack({
  topOffset = 64,
  peek = 12,
  scrollContainerRef,
  className,
  children,
  ...props
}: StickyStackProps) {
  const ref = React.useRef<HTMLDivElement>(null);
  const { scrollYProgress } = useScroll({
    target: ref,
    container: scrollContainerRef,
    offset: ["start start", "end end"],
  });

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

  const context = React.useMemo(
    () => ({ progress: scrollYProgress, total }),
    [scrollYProgress, 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 start = (index + 0.25) / total;
  const end = (index + 1) / total;

  const scale = useTransform(
    progress,
    [start, end],
    isLast || reducedMotion ? [1, 1] : [1, 0.97],
  );
  const opacity = useTransform(
    progress,
    [start, end],
    isLast || reducedMotion ? [1, 1] : [1, 0.82],
  );

  return (
    <motion.div
      data-slot="sticky-stack-item"
      style={{
        scale,
        opacity,
        top: `calc(var(--sticky-stack-top) + ${index} * var(--sticky-stack-peek))`,
      }}
      className="sticky origin-top"
    >
      {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}
    />
  );
}