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.
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.
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.
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.
Deterministic progress
Map container scroll progress to an index so every scroll position resolves to a stable narrative state.
index = round(progress * (steps - 1))
Cross-fade transitions
Animate content opacity and slight translation only. Avoid layout-affecting transitions during scroll.
Prefer opacity + transform; avoid width/height animations.
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.
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.
- Animate transform and opacity only. Avoid width/height/left/top during scroll.
- Use requestAnimationFrame throttling for scroll handlers.
- Respect reduced motion and provide a non-animated state transition path.
- Instrument frame stability in dev to detect dropped frames early.
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.