CSS scroll timelines: animations driven by scrolling

A scroll-driven animation is an ordinary @keyframes animation whose clock is replaced by a scroll position. One property, animation-timeline, makes the switch, and no JavaScript runs.

A CSS scroll timeline replaces the clock of an animation with a scroll position. You write normal @keyframes, then set animation-timeline: scroll().

At the top of the scroll range the animation sits at 0%, at the bottom it sits at 100%, and scrolling back up plays it in reverse.

Scroll inside the example. The green bar is the whole technique: one keyframes rule and two animation properties.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Reading progress bar</title>
<style>
  body {
    margin: 0; font-family: system-ui, sans-serif;
    line-height: 1.6; color: #1d2330; background: #fff;
  }
  .progress {
    position: fixed; top: 0; left: 0; right: 0; height: 6px;
    background: #16a34a;
    transform-origin: left;
    animation: grow linear both;     /* linear: bar length = scroll position */
    animation-timeline: scroll(root); /* after the shorthand, never before */
  }
  @keyframes grow {
    from { transform: scaleX(0); }
    to   { transform: scaleX(1); }
  }
  main { max-width: 560px; margin: 0 auto; padding: 24px 18px 40px; }
  h1 { font-size: 22px; margin: 8px 0 12px; }
  p { margin: 0 0 14px; }
</style>
</head>
<body>
<div class="progress"></div>
<main>
  <h1>Scroll this page</h1>
  <p>The green bar at the top is a normal CSS animation. The only change is its timeline: instead of seconds, it runs on the scroll position of the page.</p>
  <p>At the top of the page the animation is at 0%, so the bar has no width. At the bottom it is at 100%, so the bar spans the page.</p>
  <p>Scroll back up and the bar shrinks again. The animation follows the scroll position in both directions, because it has no clock of its own.</p>
  <p>No JavaScript runs here. There is no scroll listener and no requestAnimationFrame loop, only a keyframes rule and two animation properties.</p>
  <p>The keyframes animate transform: scaleX() rather than width, which keeps the work away from layout.</p>
  <p>The timing function is linear. With the default ease, the bar would run ahead of the scroll position in the middle of the page.</p>
  <p>You have reached the end. The bar should now be full.</p>
</main>
</body>
</html>
A reading progress bar with no JavaScript. Edit the code and the example reruns.

The core of it:

.progress {
  transform-origin: left;
  animation: grow linear both;
  animation-timeline: scroll(root);
}
@keyframes grow {
  from { transform: scaleX(0); }
  to   { transform: scaleX(1); }
}

If @keyframes, fill modes and the shorthand are new to you, CSS animation keyframes covers them one property at a time. This guide only changes where the progress comes from.

Three rules that trip people up

Getting any one of these wrong makes the bar look broken.

  1. Write animation-timeline after the animation shorthand. The shorthand resets animation-timeline to auto, the normal time-based timeline. Written before it, your timeline is thrown away and the animation finishes instantly.
  2. Use linear timing. The default is ease. On a scroll timeline, easing bends the mapping between scroll and progress. A quarter of the way down, an ease bar is already well past a quarter.
  3. Leave out the duration. The scroll distance sets the pace. Add both as the fill mode so the start and end states hold outside the active range.

scroll() and view(): two kinds of progress

There are two anonymous timelines, written as functions right inside animation-timeline.

scroll() measures the scroller. view() measures one element crossing the visible area.
scroll() measures the scroller. view() measures one element crossing the visible area.
  • scroll(<scroller> <axis>) tracks a scroll container. The scroller is nearest (the default, the closest scrolling ancestor), root (the page) or self. The axis is block (the default), inline, x or y.
  • view(<axis> <inset>) tracks the animated element itself. Progress is 0% when it starts to enter the scroll container's visible area and 100% when it has fully left.
scroll() view()
Progress comes from The scroll container's position This element's position in the visible area
Same value for every element? Yes No, each element has its own
Typical use Progress bars, header that shrinks Reveal on scroll, parallax per card
Default range Whole scroll range From entering to fully gone

Choosing a range with animation-range

A view() timeline covers the whole trip through the visible area. For a fade-in that is usually too long: the card only reaches full opacity when it is about to leave at the top. animation-range picks the part of the timeline the animation runs in.

The named ranges of a view() timeline, and the moment each one starts and ends.
The named ranges of a view() timeline, and the moment each one starts and ends.
  • cover is the full trip and the default.
  • entry runs while the element comes in, until it is fully inside.
  • contain runs while it is fully inside.
  • exit runs while it leaves.

You can also give a start and an end with offsets, such as animation-range: entry 0% entry 80% or entry 20% cover 50%.

The longhands are animation-range-start and animation-range-end. On a scroll() timeline, use plain percentages, such as 0% 50% for the first half of the page.

Compare the two columns below. Card 2 sits in the middle of the frame in both. On the left it is still half faded; on the right it finished when it was fully in view.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Reveal on scroll: two ranges</title>
<style>
  body { margin: 0; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  .hint { text-align: center; padding: 24px 16px 150px; font-size: 15px; }
  .cols { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; padding: 0 12px 200px; }
  .cols h2 { font: 700 13px ui-monospace, Consolas, monospace; margin: 0 0 4px; }
  .card {
    height: 110px; margin-bottom: 18px; border-radius: 12px;
    background: #fff; box-shadow: 0 4px 14px rgba(0, 0, 0, .1);
    display: grid; place-items: center; font-weight: 600;
    animation: reveal linear both;
    animation-timeline: view();          /* progress = this card crossing the frame */
  }
  .entry .card { animation-range: entry; } /* finish once the card is fully in view */

  @keyframes reveal {
    from { opacity: 0; transform: translateY(40px) scale(.9); }
    to   { opacity: 1; transform: none; }
  }
</style>
</head>
<body>
<p class="hint">Scroll down and compare the two columns.</p>
<div class="cols">
  <section class="cover">
    <h2>default range (cover)</h2>
    <div class="card">1</div><div class="card">2</div><div class="card">3</div>
    <div class="card">4</div><div class="card">5</div>
  </section>
  <section class="entry">
    <h2>animation-range: entry</h2>
    <div class="card">1</div><div class="card">2</div><div class="card">3</div>
    <div class="card">4</div><div class="card">5</div>
  </section>
</div>
</body>
</html>
Left: default range (cover). Right: animation-range: entry. Same keyframes on both.

The reveal needs one more piece: fill mode both. Outside its range, an animation without a fill does not apply, so the card shows its normal style.

With a range such as entry 50% entry 100%, the card would appear at full opacity, then snap to transparent when the range starts and fade in again. both holds the from state until then.

Named timelines and timeline-scope

Anonymous timelines only reach the element's own scrolling ancestor. When the animated element is somewhere else, name the timeline on the scroller and refer to the name:

.gallery { overflow-x: auto; scroll-timeline: --gallery x; }
.marker  { animation: slide linear both; animation-timeline: --gallery; }

scroll-timeline is shorthand for scroll-timeline-name and scroll-timeline-axis. The same pair exists for view timelines: view-timeline-name and view-timeline-axis, set on the element being watched. Names start with two dashes.

A name is visible only inside the element that declares it. A sibling such as a position marker next to the gallery cannot see it.

The marker is a sibling of the gallery. timeline-scope on the shared parent makes the name reach it.
The marker is a sibling of the gallery. timeline-scope on the shared parent makes the name reach it.

timeline-scope: --gallery on a common ancestor lifts the name up to that ancestor. Anything inside it can then use the timeline.

Feature detection and a fallback

Scroll-driven animations are newer than regular CSS animations, so check before relying on them. Two rules keep a page safe:

  • Put every scroll-driven rule inside @supports (animation-timeline: scroll()). Browsers without the feature skip the block.
  • Never hide content outside that block. If opacity: 0 lives only in the keyframes, unsupported browsers just show the content.
@supports (animation-timeline: scroll()) {
  .progress { animation: grow linear both; animation-timeline: scroll(root); }
  section   { animation: reveal linear both; animation-timeline: view();
              animation-range: entry 0% entry 80%; }
}
@media (prefers-reduced-motion: reduce) {
  section { animation: none; }
}

For an effect that matters, add a script path. CSS.supports('animation-timeline: scroll()') gives the same answer in JavaScript. The finished example uses it to drive the bar from a scroll listener when the CSS version is missing. For reveals in JavaScript, IntersectionObserver is the usual tool.

A finished page

This page combines all of it: a progress bar on scroll(root), sections that fade in on view() with a custom range, and a gallery marker that uses a named timeline through timeline-scope. The line under the title tells you which path your browser took.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Scroll-driven page with a fallback</title>
<style>
  body { margin: 0; font-family: system-ui, sans-serif; line-height: 1.6; color: #1d2330; background: #fff; }
  main { max-width: 580px; margin: 0 auto; padding: 22px 16px 60px; }
  h1 { font-size: 22px; margin: 6px 0 10px; }
  h2 { font-size: 17px; margin: 0 0 6px; }
  .note { font-size: 13px; color: #4b5563; }

  /* 1. Reading progress bar */
  .progress {
    position: fixed; top: 0; left: 0; right: 0; height: 6px; z-index: 1;
    background: #16a34a; transform-origin: left;
    transform: scaleX(var(--p, 0));   /* used by the JavaScript fallback */
  }

  /* 2. Sections that fade in */
  section { margin: 26px 0; padding: 16px; border-radius: 12px; background: #f4f5f7; }

  /* 3. Gallery with a position marker outside the scroller */
  .gallery-wrap { timeline-scope: --gallery; } /* lets the marker see the gallery's timeline */
  .gallery {
    display: flex; gap: 10px; overflow-x: auto;
    scroll-timeline: --gallery x;               /* name + axis */
  }
  .slide { flex: 0 0 70%; height: 120px; border-radius: 10px; }
  .track { position: relative; height: 6px; margin-top: 10px; border-radius: 3px; background: #e5e7eb; }
  .marker { position: absolute; left: 0; top: 0; width: 25%; height: 100%; border-radius: 3px; background: #2563eb; }

  @supports (animation-timeline: scroll()) {
    .progress { animation: grow linear both; animation-timeline: scroll(root); }
    section { animation: reveal linear both; animation-timeline: view(); animation-range: entry 0% entry 80%; }
    .marker { animation: slide linear both; animation-timeline: --gallery; }
  }
  @media (prefers-reduced-motion: reduce) {
    section { animation: none; }  /* keep the progress bar, drop the movement */
  }

  @keyframes grow { from { transform: scaleX(0); } to { transform: scaleX(1); } }
  @keyframes reveal { from { opacity: 0; transform: translateY(30px); } to { opacity: 1; transform: none; } }
  @keyframes slide { to { left: 75%; } }
</style>
</head>
<body>
<div class="progress"></div>
<main>
  <h1>A page that reacts to scrolling</h1>
  <p class="note" id="mode"></p>
  <p>Three effects share this page: the bar at the top, the sections below, and the marker under the gallery.</p>
  <section><h2>Progress bar</h2><p>The bar runs on scroll(root), the scroll position of the whole page.</p></section>
  <section><h2>Reveal</h2><p>Each section runs on its own view() timeline and is fully visible by the time 80% of its entry is done.</p></section>
  <section>
    <h2>Gallery</h2>
    <div class="gallery-wrap">
      <div class="gallery">
        <div class="slide" style="background:linear-gradient(135deg,#86efac,#16a34a)"></div>
        <div class="slide" style="background:linear-gradient(135deg,#93c5fd,#2563eb)"></div>
        <div class="slide" style="background:linear-gradient(135deg,#fcd34d,#d97706)"></div>
        <div class="slide" style="background:linear-gradient(135deg,#f9a8d4,#db2777)"></div>
      </div>
      <div class="track"><div class="marker"></div></div>
    </div>
    <p>Swipe the gallery sideways. The marker is a sibling of the gallery, so it can only use the named timeline because the wrapper declares timeline-scope.</p>
  </section>
  <section><h2>Fallback</h2><p>Browsers without scroll-driven animations skip the @supports block. The sections simply show, and a small script keeps the bar working.</p></section>
  <section><h2>The end</h2><p>The bar should be full now.</p></section>
</main>

<script>
  const supported = CSS.supports('animation-timeline: scroll()');
  document.getElementById('mode').textContent = supported
    ? 'This browser runs the CSS version.'
    : 'This browser runs the JavaScript fallback for the bar.';

  if (!supported) {
    const bar = document.querySelector('.progress');
    const update = () => {
      const max = document.documentElement.scrollHeight - innerHeight;
      bar.style.setProperty('--p', max > 0 ? scrollY / max : 0);
    };
    addEventListener('scroll', update, { passive: true });
    addEventListener('resize', update);
    update();
  }
</script>
</body>
</html>
Progress bar, reveal on scroll, and a gallery marker. Wrapped in @supports with a script fallback for the bar.

The prefers-reduced-motion query drops the moving reveal and keeps the progress bar, which shows position rather than decoration.

When it does not work

What you see Cause Fix
The element sits at its end state and never moves animation shorthand written after animation-timeline Put animation-timeline last
Same symptom with a named timeline The name does not reach the element (sibling, cousin) Add timeline-scope on a shared ancestor
Nothing animates, the element shows its normal style The scroller has nothing to scroll, so its timeline is inactive Check overflow and that content is taller or wider than the box
The bar runs ahead of the scroll in the middle Default ease timing Use linear
A reveal only finishes when the card leaves the top The default range is cover animation-range: entry
A card shows fully, then snaps transparent partway in No fill, and the range starts after the card is visible Add both to the shorthand
Horizontal scroller does not drive anything Timeline axis is block by default scroll(nearest x) or scroll-timeline: --name x
Nothing happens in one browser No support for scroll-driven animations @supports block plus a script fallback

For time-based animations that refuse to start, see CSS animation not working.

Scroll effects are hard to show in a screenshot: the whole point is what happens while you scroll. Send the page itself instead.

Paste the page into a NOS document and choose Create share link. HTML to link walks through it.

The page renders as written and its scripts run, so the people you send it to can scroll it and watch the bar fill. If you change the code later, the same link shows the new version.

Questions people ask

What is a CSS scroll timeline?

A timeline whose progress comes from a scroll position instead of elapsed time. At the start of the scroll range the animation is at 0%, at the end it is at 100%. You attach it with animation-timeline: scroll() or with a named timeline set by scroll-timeline-name.

What is the difference between scroll() and view()?

scroll() follows how far a scroll container has scrolled, so every element on it shares one progress value. view() follows one element as it crosses the visible area of its scroll container, so each element gets its own progress. Use scroll() for progress bars and view() for reveal effects.

Why does my scroll-driven animation jump straight to the end?

Usually the animation shorthand is written after animation-timeline. The shorthand resets animation-timeline to auto, so the animation runs on time with no duration and finishes at once. Put animation-timeline after the shorthand. A timeline name that does not reach the element causes the same symptom.

Do I need a duration for a scroll-driven animation?

No. Leave it out. On a scroll timeline the length of the scroll range sets the pace, and animation-range decides which part of that range the animation uses.

How do I support browsers without scroll-driven animations?

Put the scroll-driven rules inside @supports (animation-timeline: scroll()) and style the page so it works without them: content visible, no hidden starting states. For effects you still want, check CSS.supports() in JavaScript and run a scroll listener or IntersectionObserver instead.

Keep reading