Understanding the CSS Scroll-Timeline API

The CSS Scroll-Driven Animations Level 1 specification shipped in Chrome 115 (July 2023) and Safari 17.4 (March 2024). It exposes two timeline functions — scroll() and view() — that map scroll position directly to animation progress, delegating interpolation off the main thread onto the compositor, which processes frames independently of JavaScript execution. For engineers transitioning from window.addEventListener('scroll') listeners, this means scroll-coupled motion without per-frame JavaScript cost.

Pages in this section


How scroll() and view() work

scroll() vs view() timeline functions Two side-by-side diagrams. Left: scroll() maps 0–100% to total scroll distance of a container. Right: view() maps 0–100% to an element's journey through the viewport. scroll() view() 0% 100% progress = scrollTop / (scrollHeight − clientHeight) viewport element entry → exit entry 0% exit 100% progress = element's journey through viewport

scroll() tracks how far a scroll container has scrolled — progress runs from 0 (scroll start) to 1 (scroll end), calculated as scrollTop / (scrollHeight - clientHeight).

view() tracks how much of a specific element is visible within a scroll container. Progress starts when the element begins to enter the viewport and ends when it has fully exited.

Both functions accept an optional scroll-container argument and an optional axis:

/* root viewport, vertical axis (both are defaults) */
animation-timeline: scroll(root block);

/* nearest scrollable ancestor, horizontal axis */
animation-timeline: scroll(nearest inline);

/* element visibility on the vertical axis */
animation-timeline: view(block);

Syntax reference

/* scroll() — anonymous scroll timeline */
animation-timeline: scroll( <scroller>? <axis>? );

/*
  <scroller>  : nearest | root | self
  <axis>      : block | inline | x | y
  Defaults    : nearest, block
*/

/* view() — anonymous view timeline */
animation-timeline: view( <axis>? <inset>? );

/*
  <axis>      : block | inline | x | y  (default: block)
  <inset>     : <length-percentage>{1,2}  (default: auto)
*/

/* Named scroll timeline */
scroll-timeline-name: --my-timeline;
scroll-timeline-axis: block;          /* block | inline | x | y */

/* Named view timeline */
view-timeline-name: --my-view;
view-timeline-axis: block;

/* Shorthand */
scroll-timeline: --my-timeline block;
view-timeline:   --my-view    block;

/* Range */
animation-range: <range-start> <range-end>;
/*
  Keywords: normal | entry | exit | cover | contain
  Values  : entry 0%, cover 50%, exit 100%, etc.
*/

Minimal working example

The following snippet runs in any browser that supports the API with zero dependencies. A sticky header fades out as the user scrolls through the first 200 px of the page:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Scroll-driven fade header</title>
  <style>
    @keyframes fade-header {
      from { opacity: 1; transform: translateY(0); }
      to   { opacity: 0; transform: translateY(-100%); }
    }

    .header {
      position: sticky;
      top: 0;
      /* 1. Attach the animation */
      animation: fade-header linear;
      /* 2. Drive it with the root scroll position */
      animation-timeline: scroll(root block);
      /* 3. Activate only over the first 200 px of scroll */
      animation-range: 0px 200px;
      /* 4. Promote to compositor layer */
      will-change: opacity, transform;
    }

    body { height: 300vh; margin: 0; }
  </style>
</head>
<body>
  <header class="header">Sticky header — fades out as you scroll</header>
</body>
</html>

animation-range narrows the active scroll window to the first 200 px; without it the animation would span the entire document. will-change: opacity, transform promotes the element to its own compositor layer so the browser can interpolate both properties without touching the main thread.


animation-range and timeline scoping

animation-range accepts any combination of range-start and range-end values. The named keywords map to specific moments in an element’s journey:

Keyword Meaning
entry 0% element’s leading edge crosses the scroll-port’s end edge (begins entering)
entry 100% element’s trailing edge crosses the scroll-port’s end edge (fully entered)
cover 0% element begins to cover any part of the scroll-port
cover 50% element is half-way through covering the scroll-port
contain 0% element is fully contained within the scroll-port (start)
contain 100% element is still fully contained within the scroll-port (end)
exit 0% element’s leading edge crosses the scroll-port’s start edge (begins exiting)
exit 100% element’s trailing edge clears the scroll-port’s start edge (fully exited)

A reveal-on-scroll pattern that only activates while an element enters the viewport:

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

.card {
  animation: reveal linear both;
  animation-timeline: view(block);
  animation-range: entry 0% entry 100%;
}

Named timelines for scoped containers

When you need to bind a timeline to a specific scroll container rather than the nearest ancestor or the root:

/* 1. Declare the timeline on the scroll container */
.scroll-container {
  scroll-timeline-name: --product-list;
  scroll-timeline-axis: block;
  overflow-y: auto;
  height: 400px;
}

/* 2. Reference it on any descendant */
.animated-child {
  animation: slide-in linear;
  animation-timeline: --product-list;
  animation-range: 10% 60%;
}

The animated element does not need to be a direct child of the scroll container — --product-list is resolved by walking up the DOM tree to find the nearest ancestor that declares that scroll-timeline-name.


Compositor-safe properties

Only properties that the browser can animate on the compositor thread avoid main-thread recalculation during every scroll tick.

Property Compositor? Notes
opacity Yes Promotes element to own layer automatically
transform Yes Includes translate, rotate, scale, matrix
filter (blur, brightness, etc.) Partial filter: blur() triggers raster on some engines; benchmark case-by-case
clip-path No Forces paint on every frame
background-color No Triggers paint; use opacity on a pseudo-element instead
width / height No Forces layout + paint
font-size No Forces layout
border-radius No Triggers repaint on WebKit

will-change: transform or will-change: opacity signals the browser to pre-promote the layer. Do not apply will-change to every animated element — it consumes GPU memory. Reserve it for elements that animate continuously or at high frequency.

For a full explanation of why certain properties are safe and how the rendering pipeline routes work through style, layout, paint, and composite phases, see the rendering pipeline deep-dive.


Common implementation patterns

1. Reading progress bar

A full-width bar that grows as the user reads — the canonical use case for scroll(root block):

@keyframes grow-bar {
  from { transform: scaleX(0); }
  to   { transform: scaleX(1); }
}

.reading-progress {
  position: fixed;
  top: 0; left: 0;
  width: 100%;
  height: 4px;
  background: oklch(55% 0.2 300);  /* mauve accent */
  transform-origin: left center;
  animation: grow-bar linear;
  animation-timeline: scroll(root block);
  will-change: transform;
}

See the complete implementation in Creating a Reading Progress Bar with scroll-timeline.

2. Scroll-reveal card

Each card fades and slides in as it enters the viewport — uses view() so each card triggers independently:

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

.card {
  animation: card-reveal linear both;
  animation-timeline: view(block);
  animation-range: entry 0% entry 60%;
}

3. Sticky header with direction-aware transition

A header that hides when scrolling down and reappears when scrolling up, driven purely by scroll position. For a complete walkthrough see Animating Sticky Headers on Scroll Direction Change:

@keyframes hide-header {
  from { transform: translateY(0); }
  to   { transform: translateY(-100%); }
}

.site-header {
  position: sticky;
  top: 0;
  animation: hide-header linear;
  animation-timeline: scroll(root block);
  animation-range: 80px 160px;
  will-change: transform;
}

Map a horizontal translate to a vertical scroll position for a sideways reveal:

@keyframes slide-gallery {
  from { transform: translateX(0); }
  to   { transform: translateX(calc(-100% + 100vw)); }
}

.gallery-track {
  display: flex;
  animation: slide-gallery linear;
  animation-timeline: scroll(root block);
  /* Active while the gallery's container covers the viewport */
  animation-range: cover 0% cover 100%;
  will-change: transform;
}

Browser support and @supports guard

Feature Chrome Safari Firefox Edge
animation-timeline: scroll() 115 (Jul 2023) 17.4 (Mar 2024) 110 (flag only) 115 (Jul 2023)
animation-timeline: view() 115 (Jul 2023) 17.4 (Mar 2024) 110 (flag only) 115 (Jul 2023)
animation-range keywords 115 (Jul 2023) 17.4 (Mar 2024) 110 (flag only) 115 (Jul 2023)
Named scroll-timeline-name 115 (Jul 2023) 17.4 (Mar 2024) 110 (flag only) 115 (Jul 2023)

Firefox ships the full API behind layout.css.scroll-driven-animations.enabled as of Firefox 110 and has not yet enabled it by default.

The safest guard pattern wraps all scroll-driven rules in a single @supports block:

/* Static fallback — applies everywhere */
.card {
  opacity: 1;
  transform: none;
}

/* Scroll-driven enhancement — applies only when the API is available */
@supports (animation-timeline: scroll()) {
  .card {
    animation: card-reveal linear both;
    animation-timeline: view(block);
    animation-range: entry 0% entry 60%;
  }
}

For environments where Safari < 17.4 represents a significant share of your audience, the requestAnimationFrame + CSS custom property fallback is covered in detail in how to polyfill scroll-timeline for Safari.

For the full cross-pillar strategy on @supports guards and polyfill decision trees, see Browser Support and Progressive Enhancement.


Gotchas and failure modes

  1. animation-fill-mode is required to hold the end state. Without animation-fill-mode: both, the element snaps back to its from state when the scroll range ends. Add both or forwards to every scroll-driven animation that should remain at its terminal position.

  2. overflow: hidden on an ancestor breaks view timelines. A view() timeline observes scroll position in the nearest scroll container. If an ancestor has overflow: hidden, it creates a new stacking context but does not create a scroll container — the timeline may resolve against an unintended container. Audit ancestor overflow values when a view() timeline appears not to activate.

  3. Named timelines do not cross shadow DOM boundaries. scroll-timeline-name references are resolved within the same document tree. A Web Component’s shadow root cannot directly consume a named timeline declared in the light DOM. Use the anonymous scroll(root) form or pass coordinates through CSS custom properties as a workaround.

  4. will-change on scroll containers interferes with sticky positioning. Applying will-change: transform to a scroll container promotes it to a new stacking context, which breaks position: sticky children because sticky elements position relative to the nearest scroll container. Keep will-change on animated children, not the container.

  5. Conflicting inline styles override timeline interpolation. React components that set style={{ opacity: 0 }} or similar will override the animation even when the timeline is attached. Remove inline style overrides or drive the property exclusively through the timeline.

  6. animation-range with pixel units is relative to the scroll container, not the viewport. animation-range: 0px 200px means the first 200 px of total scroll distance, not the first 200 px of the visible viewport. When you need viewport-relative activation, use entry and cover percentage keywords with view() instead.


Performance checklist

  • Use opacity and transform exclusively for compositor-bound scroll animation; avoid background-color, width, height, or clip-path in @keyframes attached to a scroll timeline.
  • Apply will-change: opacity, transform only to elements that animate continuously — not to every card on the page.
  • Scope animation-range tightly using entry and exit keywords so animations are only active while the element is on-screen; a wildly broad range keeps the compositor layer live when it does not need to be.
  • Prefer named timelines (scroll-timeline-name) over root-scroll fallbacks for components inside modal overlays or fixed panels — it prevents the wrong container resolving as the timeline source.
  • Benchmark with the Performance panel in DevTools: record a scroll session and confirm there are zero Layout events triggered during scroll, and that your animated elements appear in the Layers panel as their own compositor layers.
  • For view() timelines on large lists, consider content-visibility: auto on off-screen items — it pauses rendering work without breaking the timeline, since the element’s scroll position is still tracked.
  • Avoid attaching more than one independent scroll timeline to the same element via multiple animation-* declarations; instead, use animation shorthand with a comma-separated list to keep the compositor layer count predictable.

Up: Core Animation Fundamentals & Browser Mechanics