Scroll-driven reveals
Let content fade and rise into place as it enters the viewport — an enhancement that must never hide content when JavaScript doesn't run.
In one line: as a card scrolls into view it fades and rises into place, which gives the page a sense of arrival — but the content has to be fully readable even when none of that motion ever runs.
What it is
A scroll-driven reveal is a small animation tied to position rather than time: an element starts slightly transparent and offset, and when it enters the viewport it settles to its resting state. You see it as content "arriving" — a row of cards lighting up in sequence as you scroll past them.
The key word is reveal, not appear. The element is already in the document, already laid out, already taking up its space. All you change is how it looks on the way in: opacity and a small transform. Nothing about the meaning of the page depends on the animation having played. That distinction is what separates a reveal from content that is genuinely hidden until some script decides to show it.
Why it matters
Done well, reveals guide attention. They turn a flat wall of content into a paced experience, so the eye lands on one thing, then the next, instead of taking in everything at once. The motion is a hint about reading order.
Done badly, they break the page. The classic failure is treating the start state — opacity: 0 — as the real state, then relying on JavaScript to flip it. If the script fails to load, the observer never fires, or the user has scripting off, the content stays invisible forever. You've animated your own content into a blank screen. So the rule is fixed: a reveal is an enhancement layered on top of content that is visible by default. The animation is the bonus; the words are the contract. Everything else here — observing instead of listening, revealing once, respecting reduced motion — follows from keeping that contract.
See it
Tweak it2
How it works
The mechanism has four parts, and the order they go together in matters.
-
Detect entry without scroll math. An
IntersectionObservertells you when an element crosses into a container, asynchronously and off the main thread. You never readscrollTopor attach a scroll listener — those run on every frame and invite jank. In React with Motion,useInViewwraps the same observer. Because this demo scrolls inside a panel rather than the window, you pass that panel as the observer'sroot. -
Reveal once. The first time a card enters, animate it to its resting state and stop watching it (
once: true, orunobserve()in vanilla). A reveal that replays every time the element re-enters is distracting and reads as a bug. -
Stagger the sequence. Give each successive item a small
delayso the group resolves as a line rather than a flash. Two or three hundredths of a second per item is usually enough to feel intentional without feeling slow. -
Stay visible without JavaScript. This is the part most tutorials skip. The honest pattern keeps content at full opacity by default and only adds the hidden start state once the script is running and able to reveal it. The CSS tab below shows the discipline: an
.is-readyclass (set by JS) is what dims the items; without it, everything is simply on screen.
A note on the platform: CSS now has native scroll-driven animations (an animation-timeline tied to scroll position), which can move this logic out of JavaScript entirely. Support and exact syntax are still uneven across browsers, so verify it against current docs before you ship it — but the principles here don't change. Observe position, reveal once, and never let the start state hide content that has to be readable on its own.
Build it
/*
* Default = fully visible. The hidden start state only applies after JS adds
* `.is-ready` to the root, so no-JS users read everything normally.
*/
.reveal {
transition: opacity 400ms ease, transform 400ms ease;
}
.is-ready .reveal {
opacity: 0;
transform: translateY(16px);
will-change: opacity, transform;
}
/* JS adds .is-in the first time the element enters the viewport. */
.is-ready .reveal.is-in {
opacity: 1;
transform: none;
}
/* Show everyone at once, with no movement, when motion isn't wanted. */
@media (prefers-reduced-motion: reduce) {
.reveal,
.is-ready .reveal,
.is-ready .reveal.is-in {
opacity: 1;
transform: none;
transition: none;
}
}import { useRef } from "react";
import { motion, useInView, useReducedMotion } from "motion/react";
function RevealCard({ root, delay, children }) {
const ref = useRef(null);
const reduce = useReducedMotion();
// Observe against the scroll container; reveal once on first entry.
const inView = useInView(ref, { root, once: true, amount: 0.4 });
const hidden = reduce ? { opacity: 1 } : { opacity: 0, y: 16 };
return (
<motion.div
ref={ref}
initial={hidden}
animate={reduce || inView ? { opacity: 1, y: 0 } : hidden}
transition={reduce ? { duration: 0 } : { duration: 0.4, ease: [0, 0, 0.2, 1], delay }}
className="reveal-card"
>
{children}
</motion.div>
);
}
export function RevealList({ items }) {
const root = useRef(null);
return (
<div ref={root} style={{ height: 288, overflowY: "auto" }}>
{items.map((item, i) => (
// Stagger by index; content is real children, present in the DOM.
<RevealCard key={item.id} root={root} delay={i * 0.08}>
{item.text}
</RevealCard>
))}
</div>
);
}Make it yours
Use the controls beside the demo above to change effect and stagger — each change updates the example live.
Experiment in the playgroundReproduce it with an LLM
Reproduce it with an LLM
You are a senior front-end engineer. Build a scroll reveal: items fade and rise into place as they enter the viewport, staggered slightly. Build it in React using the Motion library's in-view hook (or the IntersectionObserver directly). Requirements: content must be fully present and readable even if JavaScript never runs (reveal is an enhancement, not a gate); each item animates once when it first enters; animate only transform and opacity; under prefers-reduced-motion: reduce, show everything immediately with no movement; do not cause layout shift. Return only the component code with imports.
Pitfalls & accessibility
Related
Loading & skeleton states
A skeleton that mirrors the real layout makes waiting feel shorter and prevents the layout jump a spinner can't.
Motion principles
Borrowed from classic animation: timing, anticipation, follow-through, and staging turn a mechanical move into one that reads as intentional.
Easing & spring
Easing scripts a move over a fixed time; a spring simulates physics and settles on its own. Knowing which to reach for is half of good motion.
Further reading
- Your browser's IntersectionObserver reference, for the
root,rootMargin, andthresholdoptions that decide exactly when a reveal fires. - The current state of native CSS scroll-driven animations (
animation-timeline), to track when you can drop the JavaScript entirely.