Synchronize videos, 3D-models, etc. to Scroll-Driven Animations

With Scroll-Driven Animations it’s really easy to animate elements as they enter/exit/whatever. But what if you want to sync a video to that? Or maybe rotate a 3D-model as you scroll? With a little bit of JavaScript, it’s pretty easy to do so!


# The Inspiration

Earlier today I saw a tweet showing a recording of the 3D drag interaction on Polestar’s website making rounds.

As my colleague Adam Argyle cleverly noted, it’s nothing more than Scroll Snapping and Scroll-Driven Animations.

But how exactly can you achieve that Scroll-Driven Animation? It’s easy to animate elements as they enter/exit/whatever … but how can you control that 3D-model? That’s not possible with CSS/WAAPI based Scroll-Driven Animations, right? Not directly no, but but with a little bit of JavaScript you can extract the animation’s progress use that to synchronize the rotation of the 3D-model.


# The Code

If you’re here for just the code, you can import trackProgress from the @bramus/sda-utilities package.

import { trackProgress } from '@bramus/sda-utilities';

// Update text of the `.animation-subject` element with the effect progress
trackProgress(document.querySelector('.animation-subject').getAnimations()[0], (progress) => {
  document.querySelector('.animation-subject').innerText = `${(progress * 100).toFixed(5)}%`;

To see this snippet in action in, go check out the demos.

The source code of the trackProgress function itself is available on GitHub.


# How it works

At its core, the code is essentially this:

let progress = animation.effect.getComputedTiming().progress * 1;
if (animation.playState === "finished") progress = 1;
progress = Math.max(0.0, Math.min(1.0, progress)).toFixed(2);

It takes an animation and then gets the progress from the effect. The value is clamped between 0 and 1, taking its playState into account as well. This logic is looped in a requestAnimationFrame, only calling the callback when the progress has changed.

Avid readers might wonder why I’m not reading animation.currentTime.value directly. While it does give you the correct value for the full range of the timeline, it does not play nice with animation-range.

For example, when an element with a ViewTimeline has a animation-range set to entry, only the effect’s progress will from 0% to 100% during the actual entry segment. The animation itself on the other hand does not take the range information into account, and will go from 0% to 100% over the entire cover range.

You can see for yourself using the View Timeline Progress Visualizer I built. With the controls, choose a different range or change the range-start+range-end – you’ll see the numbers diverge.

In the future, it’ll be much easier as you’ll be able to read the progress directly from the animation through animation.progress. This API – part of web-animations-2 – is not available in Chrome (or any other browser) yet (CRBUG)

Alternatively, Custom Effects could also solve this. Only Safari has an experimental implementation of it at the time of writing.


# Demos

1. A <video> driven by scroll

While at CSS Day two weeks ago, I showed a demo where the playback of a <video> element was synchronized to a View Timeline. As the hotpink box crosses the screen, the video plays from start to finish.

See the Pen Control the playback of a <video> using Scroll-driven Animations by Bramus (@bramus) on CodePen.

The code uses the aforementioned trackProgress helper:

trackProgress(document.querySelector(".animation-subject").getAnimations()[0], (progress) => {
  document.querySelector(".animation-subject").innerText = `${(progress * 100).toFixed(5)}%`; // Update text content of the box
  document.querySelector("video").currentTime = (document.querySelector("video").duration * progress).toFixed(5); // Update playhead of the video


2. A 3D-model rotating as you scroll

Using the same JavaScript helper, it’s really easy to control a 3D-model embedded using <model-viewer>

const model = document.querySelector("model-viewer");
trackProgress(model.getAnimations()[0], (progress) => {
  model.orientation = `0deg 0deg ${progress * -360}deg`;

See the Pen 3D Model Scroll-Driven Animation by Bramus (@bramus) on CodePen.

This demo uses a ScrollTimeline on the body


3. Multiple 3D-models rotating

To more closely mimic the original demo, I added a more expansive demo to, your one-stop shop for all your Scroll-Driven Animations needs. The demo is located at and also embedded below.

A recording of it, is embedded at the top of this post.


# A note on performance

Note that this code to determine the progress is looped in a requestAnimationFrame. The code has been optimized to only invoke the callback when the progress value has actually changed, but that doesn’t change the fact that it tries to do something every frame.

Even only this small task – it’s only a few µs – can have a hit on performance, as using requestAnimationFrame will force composited animations to also run on the Main Thread again (in Chrome). The reason for this is that if you were to query a style value or the like in that rAF callback you would need to see the correct value. Therefore all animations their currentTime need to be synced up and a full main thread style update needs to be done.

A progress event on the animation, or CustomEffects would not have this constant rAf running, thereby being more performant.


# In Closing

As I’ve said before, Scroll-Driven Animations are really powerful. However, not all cases are covered by it. But thanks to a little bit of JavaScript, you can get it to reach into those edge cases and corners too 🙂


# Spread the word

To help spread the contents of this post, feel free to retweet its announcement tweet:


Published by Bramus!

Bramus is a frontend web developer from Belgium, working as a Chrome Developer Relations Engineer at Google. From the moment he discovered view-source at the age of 14 (way back in 1997), he fell in love with the web and has been tinkering with it ever since (more …)

Unless noted otherwise, the contents of this post are licensed under the Creative Commons Attribution 4.0 License and code samples are licensed under the MIT License

Join the Conversation


Leave a comment

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.