Animate elements on scroll with Scroll-driven animations

Learn how to work with Scroll Timelines and View Timelines to create scroll-driven animations in a declarative way.

Scroll-driven animations

Browser Support

  • Chrome: 115.
  • Edge: 115.
  • Firefox: behind a flag.
  • Safari: not supported.

Source

Scroll-driven animations are a common UX pattern on the web. A scroll-driven animation is linked to the scroll position of a scroll container. This means that as you scroll up or down, the linked animation scrubs forward or backward in direct response. Examples of this are effects such as parallax background images or reading indicators which move as you scroll.

A reading indicator atop a document, driven by scroll.

A similar type of scroll-driven animation is an animation that is linked to an element's position within its scroll container. With it, for example, elements can fade-in as they come into view.

The images on this page fade-in as they come into view.

The classic way to achieve these kinds of effects is to respond to scroll events on the main thread, which leads to two main problems:

  • Modern browsers perform scrolling on a separate process and therefore deliver scroll events asynchronously.
  • Main thread animations are subject to jank.

This makes creating performant scroll-driven animations that are in-sync with scrolling impossible or very difficult.

From Chrome version 115 there is a new set of APIs and concepts that you can use to enable declarative scroll-driven animations: Scroll Timelines and View Timelines.

These new concepts integrate with the existing Web Animations API (WAAPI) and CSS Animations API, allowing them to inherit the advantages these existing APIs bring. That includes the ability to have scroll-driven animations run off the main thread. Yes, read that correctly: you can now have silky smooth animations, driven by scroll, running off the main thread, with just a few lines of extra code. What's not to like?!

Animations on the web, a small recap

Animations on the web with CSS

To create an animation in CSS, define a set of keyframes using the @keyframes at-rule. Link it up to an element using the animation-name property while also setting an animation-duration to determine how long the animation should take. There are more animation-* longhand properties available–animation-easing-function and animation-fill-mode just to name a few–which can all be combined in the animation shorthand.

For example, here’s an animation that scales up an element on the X-axis while also changing its background color:

@keyframes scale-up {
  from {
    background-color: red;
    transform: scaleX(0);
  }
  to {
    background-color: darkred;
    transform: scaleX(1);
  }
}

#progressbar {
  animation: 2.5s linear forwards scale-up;
}

Animations on the web with JavaScript

In JavaScript, the Web Animations API can be used to achieve exactly the same. You can do this by either creating new Animation and KeyFrameEffect instances, or use the much shorter Element animate() method.

document.querySelector('#progressbar').animate(
  {
    backgroundColor: ['red', 'darkred'],
    transform: ['scaleX(0)', 'scaleX(1)'],
  },
  {
    duration: 2500,
    fill: 'forwards',
    easing: 'linear',
   }
);

This visual result of the JavaScript snippet above is identical to the previous CSS version.

Animation timelines

By default, an animation attached to an element runs on the document timeline. Its origin time starts at 0 when the page loads, and starts ticking forwards as clock time progresses. This is the default animation timeline and, until now, was the only animation timeline you had access to.

The Scroll-driven Animations Specification defines two new types of timelines that you can use:

  • Scroll Progress Timeline: a timeline that is linked to the scroll position of a scroll container along a particular axis.
  • View Progress Timeline: a timeline that is linked to the relative position of a particular element within its scroll container.

Scroll Progress Timeline

A Scroll Progress Timeline is an animation timeline that is linked to progress in the scroll position of a scroll container–also called scrollport or scroller–along a particular axis. It converts a position in a scroll range into a percentage of progress.

The starting scroll position represents 0% progress and the ending scroll position represents 100% progress. In the following visualization, you can see that the progress counts up from 0% to 100% as you scroll the scroller from top to bottom.

Visualization of a Scroll Progress Timeline. As you scroll down to the bottom of the scroller, the progress value counts up from 0% to 100%.

✨ Try it for yourself

A Scroll Progress Timeline is often abbreviated to simply “Scroll Timeline”.

View Progress Timeline

This type of timeline is linked to the relative progress of a particular element within a scroll container. Just like a Scroll Progress Timeline, a scroller’s scroll offset is tracked. Unlike a Scroll Progress Timeline, it’s the relative position of a subject within that scroller that determines the progress.

This is somewhat comparable to how IntersectionObserver works, which can track how much an element is visible in the scroller. If the element is not visible in the scroller, it is not intersecting. If it is visible inside the scroller–even for the smallest part–it is intersecting.

A View Progress Timeline begins from the moment a subject starts intersecting with the scroller and ends when the subject stops intersecting the scroller. In the following visualization, you can see that the progress starts counting up from 0% when the subject enters the scroll container and reaches 100% at the very moment the subject has left the scroll container.

Visualization of a View Progress Timeline. The progress counts up from 0% to 100% as the subject (green box) crosses the scroller.

✨ Try it for yourself

A View Progress Timeline is often abbreviated to simply “View Timeline”. It is possible to target specific parts of a View Timeline based on the subject’s size, but more on that later.

Getting practical with Scroll Progress Timelines

Creating an anonymous Scroll Progress Timeline in CSS

The easiest way to create a Scroll Timeline in CSS is to use the scroll() function. This creates an anonymous Scroll Timeline that you can set as the value for the new animation-timeline property.

Example:

@keyframes animate-it { … }

.subject {
  animation: animate-it linear;
  animation-timeline: scroll(root block);
}

The scroll() function accepts a <scroller> and an <axis> argument.

Accepted values for the <scroller> argument are the following:

  • nearest: Uses the nearest ancestor scroll container (default).
  • root: Uses the document viewport as the scroll container.
  • self: Uses the element itself as the scroll container.

Accepted values for the <axis> argument are the following:

  • block: Uses the measure of progress along the block axis of the scroll container (default).
  • inline: Uses the measure of progress along the inline axis of the scroll container.
  • y: Uses the measure of progress along the y axis of the scroll container.
  • x: Uses the measure of progress along the x axis of the scroll container.

For example, to bind an animation to the root scroller on the block axis, the values to pass into scroll() are root and block. Put together, the value is scroll(root block).

Demo: Reading progress indicator

This demo has a reading progress indicator fixed to the top of the viewport. As you scroll down the page, the progress bar grows until it takes up the full viewport width upon reaching the end of the document. An anonymous Scroll Progress Timeline is used to drive the animation.

Demo: Reading progress indicator.

✨ Try it for yourself

The reading progress indicator is positioned at the top of the page using position fixed. To leverage composited animations, not the width is being animated but the element is scaled down on the x-axis using a transform.

<body>
  <div id="progress"></div>
  …
</body>
@keyframes grow-progress {
  from { transform: scaleX(0); }
  to { transform: scaleX(1); }
}

#progress {
  position: fixed;
  left: 0; top: 0;
  width: 100%; height: 1em;
  background: red;

  transform-origin: 0 50%;
  animation: grow-progress auto linear;
  animation-timeline: scroll();
}

The timeline for the animation grow-progress on the #progress element is set to an anonymous timeline that’s created using scroll(). No arguments are given to scroll() so it will fall back to its default values.

The default scroller to track is the nearest one, and the default axis is block. This effectively targets the root scroller as that is the nearest scroller of the #progress element, while tracking its block direction.

Creating a named Scroll Progress Timeline in CSS

An alternative way to define a Scroll Progress Timeline is to use a named one. It’s a bit more verbose, but it can come in handy when you aren’t targeting a parent scroller or the root scroller, or when the page uses multiple timelines or when automatic lookups don’t work. This way, you can identify a Scroll Progress Timeline by the name that you give it.

To create a named Scroll Progress Timeline on an element, set the scroll-timeline-name CSS property on the scroll container to an identifier of your liking. The value must start with --.

To tweak which axis to track, also declare the scroll-timeline-axis property. Allowed values are the same as the <axis> argument of scroll().

Finally, to link the animation to the Scroll Progress Timeline, set the animation-timeline property on the element that needs to be animated to the same value as the identifier used for the scroll-timeline-name.

Code Example:

@keyframes animate-it { … }

.scroller {
  scroll-timeline-name: --my-scroller;
  scroll-timeline-axis: inline;
}

.scroller .subject {
  animation: animate-it linear;
  animation-timeline: --my-scroller;
}

If wanted, you can combine scroll-timeline-name and scroll-timeline-axis in the scroll-timeline shorthand. For example:

scroll-timeline: --my-scroller inline;

This demo features a step indicator shown above each image carousel. When a carousel contains three images, the indicator bar starts at 33% width to indicate you are currently looking at image one of three. When the last image is in view–determined by the scroller having scrolled to the end–the indicator takes up the full width of the scroller. A named Scroll Progress Timeline is used to drive the animation.

Demo: Horizontal carousel step indicator.

✨ Try it for yourself

The base markup for a gallery is this:

<div class="gallery" style="--num-images: 2;">
  <div class="gallery__scrollcontainer">
    <div class="gallery__progress"></div>
    <div class="gallery__entry">…</div>
    <div class="gallery__entry">…</div>
  </div>
</div>

The .gallery__progress element is absolutely positioned within the .gallery wrapper element. Its initial size is determined by the --num-images custom property.

.gallery {
  position: relative;
}


.gallery__progress {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 1em;
  transform: scaleX(calc(1 / var(--num-images)));
}

The .gallery__scrollcontainer lays out the contained .gallery__entry elements horizontally and is the element that scrolls. By tracking its scroll position, the .gallery__progress gets animated. This is done by referring to the named Scroll Progress Timeline --gallery__scrollcontainer.

@keyframes grow-progress {
  to { transform: scaleX(1); }
}

.gallery__scrollcontainer {
  overflow-x: scroll;
  scroll-timeline: --gallery__scrollcontainer inline;
}
.gallery__progress {
  animation: auto grow-progress linear forwards;
  animation-timeline: --gallery__scrollcontainer;
}

Creating a Scroll Progress Timeline with JavaScript

To create a Scroll Timeline in JavaScript, create a new instance of the ScrollTimeline class. Pass in a property bag with the source and axis that you want to track.

  • source: A reference to the element whose scroller that you want to track. Use document.documentElement to target the root scroller.
  • axis: Determines which axis to track. Similar to the CSS variant, accepted values are block, inline, x, and y.
const tl = new ScrollTimeline({
  source: document.documentElement,
});

To attach it to a Web Animation, pass it in as the timeline property and omit any duration if there was any.

$el.animate({
  opacity: [0, 1],
}, {
  timeline: tl,
});

Demo: Reading progress indicator, revisited

To recreate the reading progress indicator with JavaScript, while using the same markup, use the following JavaScript code:

const $progressbar = document.querySelector('#progress');

$progressbar.style.transformOrigin = '0% 50%';
$progressbar.animate(
  {
    transform: ['scaleX(0)', 'scaleX(1)'],
  },
  {
    fill: 'forwards',
    timeline: new ScrollTimeline({
      source: document.documentElement,
    }),
  }
);

The visual result is identical in the CSS version: the created timeline tracks the root scroller and scale the #progress up on the x-axis from 0% to 100% as you scroll the page.

✨ Try it for yourself

Getting practical with View Progress Timeline

Creating an Anonymous View Progress Timeline in CSS

To create a View Progress Timeline, use the view() function. Its accepted arguments are <axis> and <view-timeline-inset>.

  • The <axis> is the same as from the Scroll Progress Timeline and defines which axis to track. The default value is block.
  • With <view-timeline-inset>, you can specify an offset (positive or negative) to adjust the bounds when an element is considered to be in view or not. The value must be a percentage or auto, with auto being the default value.

For example, to bind an animation to an element intersecting with its scroller on the block axis, use view(block). Similar to scroll(), set this as the value for the animation-timeline property and don’t forget to set the animation-duration to auto.

Using the following code, every img will fade-in as it crosses the viewport while you scroll.

@keyframes reveal {
  from { opacity: 0; }
  to { opacity: 1; }
}

img {
  animation: reveal linear;
  animation-timeline: view();
}

Intermezzo: View Timeline ranges

By default, an animation linked to the View Timeline attaches to the entire timeline range. This starts from the moment the subject is about to enter the scrollport and ends when the subject has left the scrollport entirely.

It is also possible to link it to a specific part of the View Timeline by specifying the range that it should attach to. This can be, for example, only when the subject is entering the scroller. In the following visualization, the progress starts counting up from 0% when the subject enters the scroll container but already reaches 100% from the moment it is entirely intersecting.

A View Timeline set to track the entry range of the subject. The animation only runs while the subject is entering the scrollport.

The possible View Timeline ranges that you can target are the following:

  • cover: Represents the full range of the view progress timeline.
  • entry: Represents the range during which the principal box is entering the view progress visibility range.
  • exit: Represents the range during which the principal box is exiting the view progress visibility range.
  • entry-crossing: Represents the range during which the principal box crosses the end border edge.
  • exit-crossing: Represents the range during which the principal box crosses the start border edge.
  • contain: Represents the range during which the principal box is either fully contained by, or fully covers, its view progress visibility range within the scrollport. This depends on whether the subject is taller or shorter than the scroller.

To define a range, you must set a range-start and range-end. Each consists of range-name (see list above) and a range-offset to determine the position within that range-name. The range-offset is typically a percentage ranging from 0% to 100% but you can also specify a fixed length such as 20em.

For example, if you want to run an animation from the moment a subject enters, choose entry 0% as the range-start. To have it finished by the time the subject has entered, choose entry 100% as a value for the range-end.

In CSS, you set this using the animation-range property. Example:

animation-range: entry 0% entry 100%;

In JavaScript, use the rangeStart and rangeEnd properties.

$el.animate(
  keyframes,
  {
    timeline: tl,
    rangeStart: 'entry 0%',
    rangeEnd: 'entry 100%',
  }
);

Use the tool embedded below to see what each range-name represents and how the percentages affect the start and end positions. Try to set the range-start to entry 0% and the range-end to cover 50%, and then drag the scrollbar to see the animation result.

The View Timeline Ranges Visualizer, available at https://goo.gle/view-timeline-range-tool

Watch a recording

As you might notice while playing around with this View Timeline Ranges tools, some ranges can be targeted by two different range-name + range-offset combinations. For example, entry 0%, entry-crossing 0%, and cover 0% all target the same area.

When the range-start and range-end target the same range-name and span the entire range–from 0% up to 100%–you can shorten the value to simply the range name. For example, animation-range: entry 0% entry 100%; can be rewritten to the much shorter animation-range: entry.

Demo: Image reveal

This demo fades in the images as they enter the scrollport. This is done using an Anonymous View Timeline. The animation range has been tweaked so that each image is at full opacity when it is halfway the scroller.

Demo: Image reveal

✨ Try it for yourself

The expanding effect is achieved by using a clip-path that is animated. The CSS used for this effect is this:

@keyframes reveal {
  from { opacity: 0; clip-path: inset(0% 60% 0% 50%); }
  to { opacity: 1; clip-path: inset(0% 0% 0% 0%); }
}

.revealing-image {
  animation: auto linear reveal both;
  animation-timeline: view();
  animation-range: entry 25% cover 50%;
}

Creating a named View Progress Timeline in CSS

Similar to how Scroll Timelines have named versions, you can also create named View Timelines. Instead of the scroll-timeline-* properties you use variants that carry the view-timeline- prefix, namely view-timeline-name and view-timeline-axis.

The same type of values apply, and the same rules for looking up a named timeline apply.

Demo: Image reveal, revisited

Reworking the image reveal demo from earlier, the revised code looks like this:

.revealing-image {
  view-timeline-name: --revealing-image;
  view-timeline-axis: block;

  animation: auto linear reveal both;
  animation-timeline: --revealing-image;
  animation-range: entry 25% cover 50%;
}

Using view-timeline-name: revealing-image, the element will be tracked within its nearest scroller. The same value is then used as the value for the animation-timeline property. The visual output is exactly the same as before.

✨ Try it for yourself

Creating a View Progress Timeline in JavaScript

To create a View Timeline in JavaScript, create a new instance of the ViewTimeline class. Pass in a property bag with the subject that you want to track, axis, and inset.

  • subject: A reference to the element that you want to track within its own scroller.
  • axis: The axis to track. Similar to the CSS variant, accepted values are block, inline, x, and y.
  • inset: An inset (positive) or outset (negative) adjustment of the scrollport when determining whether the box is in view.
const tl = new ViewTimeline({
  subject: document.getElementById('subject'),
});

To attach it to a Web Animation, pass it in as the timeline property and omit any duration if there was any. Optionally, pass in range information using the rangeStart and rangeEnd properties.

$el.animate({
  opacity: [0, 1],
}, {
  timeline: tl,
  rangeStart: 'entry 25%',
  rangeEnd: 'cover 50%',
});

✨ Try it for yourself

More things to try out

Attaching to multiple View Timeline ranges with one set of keyframes

Let’s take a look at this contact list demo where the list entries are animated. As a list entry enters the scrollport from the bottom it slides+fades in, and as it exits the scrollport at the top it slides+fades out.

Demo: Contact list

✨ Try it for yourself

For this demo, each element gets decorated with one View Timeline that tracks the element as it crosses its scrollport yet two scroll-driven animations are attached to it. The animate-in animation is attached to the entry range of the timeline, and the animate-out animation to the exit range of the timeline.

@keyframes animate-in {
  0% { opacity: 0; transform: translateY(100%); }
  100% { opacity: 1; transform: translateY(0); }
}
@keyframes animate-out {
  0% { opacity: 1; transform: translateY(0); }
  100% { opacity: 0; transform: translateY(-100%); }
}

#list-view li {
  animation: animate-in linear forwards,
             animate-out linear forwards;
  animation-timeline: view();
  animation-range: entry, exit;
}

Instead of running two different animations attached to two different ranges, it is also possible to create one set of keyframes that already contains the range information.

@keyframes animate-in-and-out {
  entry 0%  {
    opacity: 0; transform: translateY(100%);
  }
  entry 100%  {
    opacity: 1; transform: translateY(0);
  }
  exit 0% {
    opacity: 1; transform: translateY(0);
  }
  exit 100% {
    opacity: 0; transform: translateY(-100%);
  }
}

#list-view li {
  animation: linear animate-in-and-out;
  animation-timeline: view();
}

As the keyframes contain the range information, you don’t need to specify the animation-range. The result is exactly the same as it was before.

✨ Try it for yourself

Attaching to a non-ancestor Scroll Timeline

The lookup mechanism for named Scroll Timelines and named View Timelines is limited to scroll ancestors only. Very often though, the element that needs to be animated is not a child of the scroller that needs to be tracked.

To make this work, the timeline-scope property comes into play. You use this property to declare a timeline with that name without actually creating it. This gives the timeline with that name a broader scope. In practice, you use the timeline-scope property on a shared parent element so that a child scroller’s timeline can attach to it.

For example:

.parent {
  timeline-scope: --tl;
}
.parent .scroller {
  scroll-timeline: --tl;
}
.parent .scroller ~ .subject {
  animation: animate linear;
  animation-timeline: --tl;
}

In this snippet:

  • The .parent element declares a timeline with the name --tl. Any child of it can find and use it as a value for the animation-timeline property.
  • The .scroller element actually defines a Scroll Timeline with the name --tl. By default it would only be visible to its children but because .parent has it set as the scroll-timeline-root, it attaches to it.
  • The .subject element uses the --tl timeline. It walks up its ancestor tree and finds --tl on the .parent. With the --tl on the .parent pointing to the --tl of .scroller, the .subject will essentially track the .scroller’s Scroll Progress Timeline.

Put differently, you can use timeline-root to move a timeline up to an ancestor (aka hoisting), so that all children of the ancestor can access it.

The timeline-scope property can be used with both both Scroll Timelines and View Timelines.

More demos and resources

All demos covered in this article on the scroll-driven-animations.style mini-site. The website includes many more demos to highlight what is possible with Scroll-driven animations.

One of the additional demos is this list of album covers. Each cover rotates in 3D as it takes the center spotlight.

Demo: Cover Flow

✨ Try it for yourself

Or this stacking cards demo that leverage position: sticky. As the cards stack, the already stuck cards scale down, creating a nice depth effect. In the end, the entire stack slides out of view as a group.

Demo: Stacking cards.

✨ Try it for yourself

Also featured on scroll-driven-animations.style is a collection of tools such as the View Timeline Range Progress visualization that was included earlier in this post.

Scroll-driven animations are also covered in What’s new in Web Animations at Google I/O ’23.