Home CSS

CSS scroll-driven animations

animation-timeline: scroll() and view() drive a CSS animation by scroll position rather than by time. Replaces a long history of IntersectionObserver + JavaScript animation code for parallax, progress bars, and reveal effects.

What scroll-driven animations are

Scroll-driven animations let CSS run an animation against a scroll rather than against the document’s wall clock. Two timeline kinds are defined:

  • scroll() — the animation progresses with the page (or named scroller) scroll position.
  • view() — the animation progresses as the target element scrolls into and out of view.
.progress-bar {
  position: fixed;
  inline-size: 100%;
  block-size: 4px;
  background: var(--c-brand-primary);
  transform-origin: left;
  animation: progress linear;
  animation-timeline: scroll();
}
@keyframes progress {
  from { transform: scaleX(0); }
  to   { transform: scaleX(1); }
}

That single declaration replaces about 30 lines of JavaScript that listened to scroll, computed scroll fraction, and updated a CSS variable.

Named timelines

The view-timeline property names a timeline driven by an element’s intersection with its scrolling ancestor. Then any animation can attach to it via animation-timeline:

.card {
  view-timeline-name: --card;
  animation: fade-in linear;
  animation-timeline: --card;
  animation-range: entry 0% cover 30%;
}
@keyframes fade-in {
  from { opacity: 0; transform: translateY(20px); }
  to   { opacity: 1; }
}

Named timelines are scoped: a view-timeline-name defined on .card is visible to descendants and to the next-sibling chain unless timeline-scope is widened.

animation-range

The animation-range property says which portion of the timeline drives the animation:

KeywordMeaning
cover 0% to cover 100%The element is entering the scrollport from any position.
entry 0% to entry 100%From the moment any pixel enters the scrollport to the moment the entry edge reaches the scrollport edge.
exit 0% to exit 100%The mirror of entry, on the way out.
contain 0% to contain 100%While the element is fully visible.

A common pattern: animate from 0% opacity at entry 0% to 100% opacity at entry 50%. The element fades in over the first half of its entry into the scrollport.

Reduced motion

The platform respects prefers-reduced-motion only if the author writes the rule. Default scroll-driven animations do run when reduced motion is set, so wrap:

@media (prefers-reduced-motion: reduce) {
  .card {
    animation: none;
  }
}

The 30%-of-macOS-Safari-users figure for reduced-motion preference (Apple’s 2023 telemetry) makes this an essential guard.

Cross-engine support

EngineSupport
Chromium115 (July 2023)
Gecko (Firefox)Behind layout.css.scroll-driven-animations.enabled flag as of 2025; ship target 2025–2026
WebKitIn development, prototype 2024; production target unset

The feature is currently a Chromium-led experiment; a runtime fallback to IntersectionObserver is the right pattern for authors who want broad coverage:

@supports (animation-timeline: scroll()) {
  .reveal {
    animation: fade-in linear;
    animation-timeline: view();
    animation-range: entry 0% entry 60%;
  }
}

Outside the @supports block, the elements remain visible (no animation needed if not supported). The pattern is progressive — users on Chromium see the polish; everyone else sees the content.

Common patterns

Reading-progress indicator

.progress {
  position: fixed; top: 0; left: 0; right: 0;
  block-size: 3px;
  background: var(--c-brand-primary);
  transform-origin: left;
  animation: progress linear;
  animation-timeline: scroll(root);
}
@keyframes progress { from { transform: scaleX(0); } to { transform: scaleX(1); } }

Hero parallax

.hero-image {
  animation: parallax linear;
  animation-timeline: view();
  animation-range: entry 0% exit 100%;
}
@keyframes parallax {
  to { transform: translateY(-30%); }
}

Sticky header that shrinks on scroll

header {
  animation: shrink linear both;
  animation-timeline: scroll(root);
  animation-range: 0 200px;
}
@keyframes shrink {
  from { padding-block: var(--space-5); }
  to   { padding-block: var(--space-2); }
}

Common pitfalls

  • Animation runs on first paint. Scroll-driven animations evaluate at scroll 0 — so an entry 0% keyframe is the initial state. Set sensible defaults outside the keyframes.
  • animation-fill-mode confusion. Use both to apply the initial keyframe before the timeline starts and the final keyframe after.
  • Heavy effects on transform/opacity only. These are composited; other properties (e.g. width) trigger layout per scroll frame. Stick to compositor-friendly properties.
  • Named view-timeline collisions. Two elements with the same view-timeline-name create ambiguity; the first ancestor wins.
  • Forgetting reduced-motion. Animate respectfully.

Further reading