How to Build Sticky Scroll Reveal Animations in Next.js
14 min read
Wed Feb 18 2026

How the Sticky Scroll Model Actually Works

Sticky scroll storytelling is not just an animation trick; it is a deterministic mapping from scroll position to narrative state. The key is to keep one surface stable (sticky frame) and change only the state rendered inside it.

A reliable implementation starts by defining geometry explicitly: if scroll progress is p in [0, 1] and you have N steps, then activeIndex = round(p * (N - 1)). This prevents drift across devices and keeps replay behavior consistent.

Layout contract

  • The sticky frame must live inside the same scroll container that owns progress.
  • Narrative sections should have roughly consistent min-heights to keep progression smooth.
  • Avoid mutating frame dimensions at runtime; only animate composited properties.
use-sticky-index.tstsx
import { useCallback, useEffect, useRef, useState } from "react";

export function useStickyIndex(stepCount: number) {
  const containerRef = useRef<HTMLDivElement>(null);
  const [activeIndex, setActiveIndex] = useState(0);

  const update = useCallback(() => {
    const node = containerRef.current;
    if (!node) return;

    const maxScrollable = node.scrollHeight - node.clientHeight;
    if (maxScrollable <= 0) {
      setActiveIndex(0);
      return;
    }

    const progress = node.scrollTop / maxScrollable;
    const next = Math.min(stepCount - 1, Math.round(progress * (stepCount - 1)));
    setActiveIndex(next);
  }, [stepCount]);

  useEffect(() => {
    const node = containerRef.current;
    if (!node) return;

    let raf = 0;
    const onScroll = () => {
      cancelAnimationFrame(raf);
      raf = requestAnimationFrame(update);
    };

    update();
    node.addEventListener("scroll", onScroll, { passive: true });
    return () => {
      cancelAnimationFrame(raf);
      node.removeEventListener("scroll", onScroll);
    };
  }, [update]);

  return { containerRef, activeIndex };
}

Working Demo (Scroll Inside the Container)

This demo uses the same model above: container-local scroll, deterministic index mapping, sticky preview surface, and minimal transition surface.

Live sticky demo. Scroll inside this container.

Scroll here
1

Sticky shell

Create a scroll container with enough narrative depth and place a sticky visual frame inside it.

CSS: position: sticky + top offset inside the scrolling parent.

2

Deterministic progress

Map container scroll progress to an index so every scroll position resolves to a stable narrative state.

index = round(progress * (steps - 1))

3

Cross-fade transitions

Animate content opacity and slight translation only. Avoid layout-affecting transitions during scroll.

Prefer opacity + transform; avoid width/height animations.

4

Accessibility + perf

Respect reduced motion and keep frame rendering cheap with containment and simple compositing.

Use prefers-reduced-motion and minimal repaint surfaces.

Active frame

Sticky shell

Create a scroll container with enough narrative depth and place a sticky visual frame inside it.

Implementation check
CSS: position: sticky + top offset inside the scrolling parent.

Performance and Accessibility Guardrails

Scroll-driven UI can degrade quickly if you animate layout properties or ignore motion preferences. Treat the effect as a performance budgeted feature.

  1. Animate transform and opacity only. Avoid width/height/left/top during scroll.
  2. Use requestAnimationFrame throttling for scroll handlers.
  3. Respect reduced motion and provide a non-animated state transition path.
  4. Instrument frame stability in dev to detect dropped frames early.
sticky-accessibility.tsxtsx
const prefersReducedMotion =
  typeof window !== "undefined" &&
  window.matchMedia("(prefers-reduced-motion: reduce)").matches;

const transition = prefersReducedMotion
  ? { duration: 0 }
  : { duration: 0.28, ease: "easeOut" };

<motion.div
  initial={{ opacity: 0, y: prefersReducedMotion ? 0 : 12 }}
  animate={{ opacity: 1, y: 0 }}
  exit={{ opacity: 0, y: prefersReducedMotion ? 0 : -8 }}
  transition={transition}
/>

Production failure mode

The most common bug is state jitter around breakpoint boundaries. Add hysteresis (a tiny deadband) if users report flicker near section transitions.