Skip to content
Motion & micro-interactionsLesson 8 of 8Intermediate9 min

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.

Kept on this device only.

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

Live demo
Tweak it2
0.08
Scroll the panel; items reveal once as they enter. Reduced motion shows them at once.

How it works

The mechanism has four parts, and the order they go together in matters.

  1. Detect entry without scroll math. An IntersectionObserver tells you when an element crosses into a container, asynchronously and off the main thread. You never read scrollTop or attach a scroll listener — those run on every frame and invite jank. In React with Motion, useInView wraps the same observer. Because this demo scrolls inside a panel rather than the window, you pass that panel as the observer's root.

  2. Reveal once. The first time a card enters, animate it to its resting state and stop watching it (once: true, or unobserve() in vanilla). A reveal that replays every time the element re-enters is distracting and reads as a bug.

  3. Stagger the sequence. Give each successive item a small delay so 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.

  4. 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-ready class (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

Reveal on scroll — content visible without JS
/*
 * 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;
  }
}

Make it yours

Use the controls beside the demo above to change effect and stagger — each change updates the example live.

Experiment in the playground

Reproduce 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

Further reading

  • Your browser's IntersectionObserver reference, for the root, rootMargin, and threshold options 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.