Reveal-on-Scroll with view() and animation-range

Reveal-on-scroll is the most requested scroll effect and historically the most over-engineered one: a library, an IntersectionObserver, a class toggle, a will-change cleanup, and a mutation observer for content added later. What the effect actually needs is a statement that an element's opacity and offset are a function of its own position in the scrollport — which is exactly what animation-timeline: view() expresses. This page builds the complete reveal, explains why animation-fill-mode is not optional here (it is the single difference between a working reveal and a flashing one), and sets the technique against IntersectionObserver so you can tell when the script is still the right answer. It builds on the mechanics laid out in the scroll-driven animations guide.


Why a Timeline Beats an Observer Here

An IntersectionObserver is an event source. It tells you that an element has crossed a threshold — 0%, 25%, whatever you configured — and hands you a callback on the main thread. From there you toggle a class, and a time-based CSS animation plays for 400ms regardless of what the reader does next. Three consequences follow from that shape, and all three are things people then spend effort papering over.

The reveal is decoupled from the gesture. Scroll fast and every card in the viewport fires at once, then plays out on its own clock while the page has already moved on. Scroll back up and nothing rewinds, because the observer only fired once. Any sense that the motion is responding to the reader is gone; it is a canned clip triggered by proximity.

The callback competes for the main thread. Threshold crossings are delivered between frames, so during heavy work they arrive late and in clumps, which is why observer-driven reveals so often appear in a burst after a stall rather than smoothly.

And there is state to manage. Elements added after the observer was created need to be observed; elements removed need to be unobserved; server-rendered content is un-revealed until hydration; and the un-revealed state is opacity: 0, so a script failure means invisible content.

A view progress timeline replaces all of that with a mapping. The element's progress through the scrollport is the animation's progress, sampled by the compositor in the same frame that applies the scroll. Scroll slowly and the card rises slowly; drag the scrollbar backwards and — if your range allows it — the card lowers again. There is nothing to observe, nothing to unobserve, and nothing to hydrate.

The observer still wins in two cases. First, when the trigger must do something that is not a style: fire analytics, start a video, lazily request data. A timeline changes property values; it cannot call a function. Second, when you need a genuine one-shot latch — "animate the first time this is seen, never again, even after a reload-free navigation" — because a timeline is stateless by design and will happily replay if the element re-enters. If you catch yourself trying to force a timeline to latch, the observer is the honest tool.

A card's reveal progress across the scrollport Three scrollport frames showing a card entering at the bottom, rising to the middle, and settling in the upper area, with the reveal complete and held at the third position. animation-range: entry 0% cover 40% entering rising settled opacity 0 opacity 0.5 opacity 1 entry 0% cover 20% cover 40% Past the range end, fill-mode both holds the final keyframe.

Complete Working Implementation

Scroll the frame. Each card rises and fades in as it crosses into view, then stays where it is — it does not fade back out at the top, because the range ends early and the fill mode holds it.

Live demoReveal on scroll with view()
Scroll down — each card fades and lifts as it enters the scrollport, then holds its final state because animation-fill-mode is both.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Reveal on scroll</title>
<style>
  body { margin: 0; font: 16px/1.6 system-ui, sans-serif; }
  .feed { display: grid; gap: 1.5rem; max-width: 34rem;
          margin: 0 auto; padding: 2rem 1.25rem 60vh; }

  .card {
    padding: 1rem 1.1rem;
    border-radius: 12px;
    border: 1px solid #d7dced;
    background: #f4f6fb;

    /* `both` is load-bearing. It applies the `from` keyframe before the
       range starts and holds the `to` keyframe after it ends. Without it
       the card renders fully visible, snaps to hidden when the range
       opens, and snaps back at the end. */
    animation: reveal linear both;

    /* An anonymous view timeline whose subject is this card. Every card
       gets its own independent 0%-100%, with no naming or wiring. */
    animation-timeline: view();

    /* Start the moment the card's leading edge crosses into the
       scrollport; be finished once the card is 40% of the way across it. */
    animation-range: entry 0% cover 40%;
  }

  @keyframes reveal {
    from { opacity: 0; transform: translateY(28px); }
    to   { opacity: 1; transform: translateY(0); }
  }

  /* Positional motion tied to the scroll gesture is a vestibular trigger.
     Under reduced motion the cards are simply present. */
  @media (prefers-reduced-motion: reduce) {
    .card {
      animation: none;
      animation-timeline: none;
      opacity: 1;
      transform: none;
    }
  }
</style>
</head>
<body>
  <div class="feed">
    <article class="card"><h3>Ingest</h3><p>Events land in the queue.</p></article>
    <article class="card"><h3>Normalise</h3><p>Fields are coerced to the schema.</p></article>
    <article class="card"><h3>Enrich</h3><p>Lookups attach account metadata.</p></article>
    <article class="card"><h3>Fan out</h3><p>Subscribers receive the record.</p></article>
    <article class="card"><h3>Archive</h3><p>Raw payloads go to cold storage.</p></article>
  </div>
</body>
</html>

Note the 60vh of bottom padding on .feed. Without it the last card never travels far enough through the scrollport for cover 40% to be reached, and it stays permanently half-revealed. This is the most common bug in view-timeline reveals and it has nothing to do with the CSS — it is a consequence of the page ending before the element's pass does.


The Key Technique: Why fill-mode Is Not Optional

A time-based animation with a delay is pending before it starts: the element sits in its own declared styles until the clock reaches the delay. A range-bounded scroll-driven animation behaves the same way, but the "before" and "after" regions are now enormous — they are every scroll position outside entry 0% to cover 40%, which for most of a long page is the majority of the element's life.

animation-fill-mode decides what happens in those two regions:

  • none (the default): outside the range the element renders with its normal, un-animated styles. A card declared without opacity: 0 therefore sits at full opacity below the fold, snaps to invisible the instant its range opens, animates in, and then snaps back to full opacity when the range closes. Two visible flashes for the price of one.
  • forwards: the final keyframe is held after the range ends — the settled state survives — but the element is still fully visible before the range opens.
  • backwards: the first keyframe applies before the range opens, so the card is correctly hidden below the fold, but after the range closes it reverts to its declared styles.
  • both: the union. Hidden before, revealed after. This is the only correct value for a reveal, and it is why the shorthand reads animation: reveal linear both.

The alternative sometimes proposed — set opacity: 0 on .card itself and use forwards — half works and is worse. It makes the hidden state the element's real style, which means the card is invisible to anyone whose browser did not apply the animation at all, whether through lack of support, a stylesheet that failed to load, or print. With both, the declared style stays opacity: 1 and the hidden state exists only inside the animation, so every failure mode degrades to visible content. Reveals must fail open.


Variation: Staggering the Reveal Without Delays

On a time-based entrance you stagger with animation-delay, as in the staggered list animations pattern. That does not work on a scroll timeline — animation-delay has no meaning when progress is geometric. The equivalent lever is the range: give consecutive items slightly later ranges, and because they also sit at different heights, the effect compounds into a cascade as the group scrolls in.

.card { animation: reveal linear both; animation-timeline: view(); }

.card:nth-child(3n + 1) { animation-range: entry 0% cover 30%; }
.card:nth-child(3n + 2) { animation-range: entry 10% cover 40%; }
.card:nth-child(3n + 3) { animation-range: entry 20% cover 50%; }

An alternative that scales better to a grid is a view() inset. view(block 20% 0%) shrinks the notional scrollport from the top by 20%, so the timeline for that element starts later in absolute scroll terms:

.card:nth-child(even) {
  animation-timeline: view(block 20% 0%);
}

Insets are also the fix for sticky headers: if a 64px bar covers the top of the scrollport, view(block 0 64px) stops elements from being considered "in view" while they are underneath it. Both variants pair naturally with size-aware components — a grid built the way responsive cards with container queries describes will reflow from three columns to one, and a range-based stagger stays coherent through that reflow in a way that a fixed per-item delay does not.


Browser Support

animation-timeline: view() and the animation-range properties shipped together in Chrome and Edge 115 and in Safari 26. Firefox has an implementation behind a preference as of mid-2026 and should be planned for as unsupported. Because the correct build declares the visible state on the element and hides it only inside the animation, an unsupported browser shows every card immediately with no guard at all — which is the right outcome. Add @supports (animation-timeline: view()) only when the enhanced version changes layout rather than just opacity and transform, and see scroll-driven animation fallbacks for how to structure that. Keep the prefers-reduced-motion branch regardless of support, since it is an obligation about the effect, not about the feature — the reasoning is set out in vestibular-safe animation patterns.


FAQ

Why is animation-fill-mode both required for a view() reveal? The animation only exists inside its range. Before the range starts and after it ends the element renders with its own styles, so without fill both it appears fully visible, jumps to the from keyframe when the range begins, and jumps back at the end. Fill both applies the from state before and holds the to state after.

How is view() different from IntersectionObserver? IntersectionObserver reports a threshold crossing as a discrete event on the main thread, and you write the animation yourself. A view timeline gives continuous scroll-linked progress evaluated by the compositor, with no event, no class toggle, and no script.

Does animation-range entry 0% cover 40% mean two different phases? Yes, and that is intentional. The start is measured in the entry phase and the end in the cover phase, so the reveal begins as the element's leading edge crosses in and finishes once it is forty percent of the way across the scrollport.

Why does my reveal never fire for content near the bottom of the page? Because that element never travels far enough through the scrollport for its range to complete. Add bottom padding to the page, shorten the range, or add a scroll-driven fallback so the final elements are visible without any scrolling.


Related articles

More pages in the same section.