A typical reveal-on-scroll script observes every card, waits for an intersection, adds a class, and leaves CSS to perform the animation. That works. It also means JavaScript owns a visual state that is entirely determined by where an element sits inside a scrollport.

CSS scroll-driven animations let the scroll position drive ordinary CSS keyframes. They are a good fit for progress indicators, restrained reveal effects, and other motion that can disappear without hiding information. They are not yet a universal replacement for every animation library.

The new part is the timeline

A normal CSS animation progresses with time. A scroll-driven animation uses the same target and the same @keyframes, but its timeline advances when the user scrolls. If scrolling stops, the animation stops at the corresponding progress value.

@keyframes reveal {
  from {
    opacity: 0;
    translate: 0 2rem;
  }

  to {
    opacity: 1;
    translate: 0 0;
  }
}

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

The animation shorthand comes first on purpose. The shorthand resets animation-timeline to its initial value, so declaring the timeline before the shorthand silently disconnects it.

scroll() and view() answer different questions

Scroll progress

.progress {
  animation-timeline: scroll(root block);
}

How far has a scroll container moved from its start to its end?

View progress

.card {
  animation-timeline: view(block);
}

How far has this element travelled through its scrollport?

Use scroll() for page progress, a horizontal gallery indicator, or an effect tied to one container. Use view() when each subject should react to its own journey into and out of view.

Build a reading progress indicator

The anonymous scroll(root block) timeline follows the root scroller on its block axis. Scaling a fixed bar avoids animating its width and makes the progress calculation easy to read.

@keyframes reading-progress {
  to { scale: 1 1; }
}

.reading-progress {
  position: fixed;
  inset: 0 0 auto;
  block-size: 0.25rem;
  transform-origin: inline-start;
  background: deeppink;
  scale: 0 1;

  animation: reading-progress 1ms linear both;
  animation-timeline: scroll(root block);
}

The 1ms duration is not the visible duration. Scroll progress replaces time as the timeline, but a non-zero duration keeps the effect interoperable with implementations that still expect one.

Live example: scroll inside the panel

StartThe timeline begins at the top of this scroller.
HalfwayThe bar follows the actual scroll position.
FinishThe animation reaches its final keyframe.

Limit a reveal to the entry range

Without an attachment range, a view() timeline spans the subject's full trip through the scrollport. A reveal usually needs only the entry portion.

.article-card {
  animation: reveal 1ms linear both;
  animation-timeline: view(block);
  animation-range: entry 10% entry 80%;
}

entry 10% starts after the subject has begun entering. entry 80% completes the reveal before the entire element is visible. Tune the range against the actual component height and scrollport, not against one ideal screenshot.

Keep the end state as the baseline. A browser without support should show the card at full opacity and in its final position. Put the initial hidden state inside a feature query rather than on the default rule.

Make unsupported browsers boring, not broken

Scroll-driven animations are available in Chromium and Safari releases, while Firefox support is still moving through experimental builds at the time of writing. This makes them a progressive enhancement, especially for public sites with a broad browser policy.

.article-card {
  opacity: 1;
  translate: 0 0;
}

@supports (animation-timeline: view()) {
  .article-card {
    animation: reveal 1ms linear both;
    animation-timeline: view(block);
    animation-range: entry 10% entry 80%;
  }
}

If the reveal communicates status or determines whether a control is available, it is not decorative anymore. Keep the application logic in JavaScript and use CSS only for the presentation.

Respect reduced motion before adding the effect

Scroll-linked movement follows every gesture, which can be uncomfortable even when the distance looks small to the designer. The safest default is to add the timeline only when the user has not requested reduced motion.

@media (prefers-reduced-motion: no-preference) {
  @supports (animation-timeline: view()) {
    .article-card {
      animation: reveal 1ms linear both;
      animation-timeline: view(block);
      animation-range: entry 10% entry 80%;
    }
  }
}

The static version is a complete experience. The moving version is an enhancement for people and browsers that accept it.

What CSS should not replace

CSS can replace an Intersection Observer that exists only to toggle a reveal class. It should not replace scroll logic that loads data, records milestones, synchronizes media, or changes application state. Those tasks need events, error handling, and a source of truth outside presentation.

  • Test both root and nested scroll containers.
  • Keep every element visible when the feature query fails.
  • Check the range at narrow widths, zoomed text, and unusually tall content.
  • Disable non-essential movement when reduced motion is requested.
  • Use JavaScript when scroll position triggers product behavior rather than decoration.

Primary references