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.
*/
scroll() and view() argument anatomy The two anonymous timeline functions broken into their arguments. scroll() takes a scroller keyword — nearest, root or self — and an axis. view() takes an axis and an optional inset that shrinks the visibility window. The key difference is what supplies progress: the scroll position for scroll(), the subject\u2019s visibility for view(). scroll( <scroller>? <axis>? ) nearest | root | self which box is scrolled block | inline | x | y which direction view( <axis>? <inset>? ) block | inline | x | y shrinks the visibility window scroll() reads the scroll position 0% at scroll start, 100% at maximum scroll the subject element is irrelevant view() reads the subject's visibility 0% as it enters the scrollport, 100% as it leaves every subject gets its own progress

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:

What each animation-range keyword covers A scrollport is drawn with the subject element at four positions: fully below, partly entering, fully contained, and leaving above. Bars beneath mark the span each named range covers — cover spans the whole journey, entry only the arrival, contain only the fully-visible window, and exit only the departure. scrollport below, 0% contained above, 100% the subject's whole journey through the scrollport cover — 0% to 100% entry contain exit enters fully visible leaves animation-range: entry 0% cover 40%; start and end may name different ranges — that is how you build an asymmetric reveal
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-topic 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.

Frequently Asked Questions

Why does scroll() pick the wrong element?

Because nearest — the default — means the nearest scrollable ancestor, and pages frequently have more scroll containers than their authors remember. A wrapper with overflow: auto set for a completely different reason, a modal body, or a component that scrolls internally will all be selected ahead of the document. The fix is either scroll(root block) when you mean the page, or a named timeline when you mean a specific container that is not the nearest one.

Diagnosing it is quick: scroll each candidate container in turn and see which one moves the animation.

Nothing happens at all — where do I start?

Check that the resolved scroller can actually scroll. A container whose content fits produces no scroll range, so progress is undefined and the animation sits at its initial state. This accounts for a large share of “the timeline does not work” reports, and it is especially common on the reduced test case built to isolate the problem, which is shorter than the real page.

After that, check the computed animation-timeline value, then the range. Those three checks in that order resolve nearly everything.

When should a range be something other than the default?

Almost always. The default range spans the subject’s entire journey through the scrollport, which means a reveal begins the instant one pixel appears and finishes as the last pixel leaves — spread thinly over far more scroll than the effect wants. Ranges like entry 25% to entry 75% concentrate the animation into the part of the journey where the reader is actually looking at the element.

Ranges are also how asymmetric effects are built: a start expressed in one named range and an end in another produces behaviour that would otherwise need two animations.

Can one element be driven by two timelines?

Not for a single animation — an animation has one timeline. An element can carry several animations, each with its own timeline, which is how an element can both fade according to its own visibility and drift according to the page’s scroll position. The constraint is the usual one: if two of those animations target the same property, one of them wins and the other is invisible.

That constraint is the reason the multi-timeline pattern normally pairs a view()-driven opacity animation with a scroll()-driven transform, rather than two animations that both want to move the element.

Does the timeline keep running when the element is off-screen?

A scroll() timeline does, because its progress depends on the scroller rather than on the subject — the value is defined whether or not anything is looking at it. A view() timeline is defined across the subject’s journey through the scrollport and clamps outside it, so an element far below the fold sits at 0% and one scrolled well past sits at 100%.

That clamping is what makes view() timelines safe to attach to every item in a long list: elements outside the scrollport are not doing per-frame work, they are simply resting at an endpoint. The cost of a hundred such timelines is close to the cost of one.


Up: Core Animation Fundamentals & Browser Mechanics