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

How to Build Sticky Scroll Reveal Animations in Next.js

A deep implementation walkthrough of scroll geometry, deterministic state mapping, motion performance budgets, and accessibility-safe sticky storytelling.

Next.js
Framer Motion
Animation
UX

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.