Modern Animation APIs
For years, scroll-linked animations required JavaScript: listening to scroll events, calculating positions, and manually updating styles on every frame. The result was often janky, battery-draining, and fragile. The browser now offers a better path. Scroll-driven animations, the View Transitions API, and the Web Animations API together give you the tools to build animations that are smooth, declarative, and integrated directly into the browser's rendering pipeline, with far less code than the JavaScript approaches they replace.
This reading introduces each of these APIs, explains how they work under the hood, and shows you where they fit in real projects. By the end, you will understand when to reach for each tool and what their current browser support limitations mean for your work.
Scroll-Driven Animations
Scroll-driven animations tie an animation's progress to scroll position rather than elapsed time. As the user scrolls, the animation advances. Scroll back, and it reverses. The key insight is that the browser handles all of this internally: no JavaScript event listeners, no requestAnimationFrame loops, no scroll position calculations.
The animation-timeline Property
The animation-timeline property is the bridge between a CSS animation and a scroll container. By default, every animation uses animation-timeline: auto, which means time-based playback. When you change this to a scroll timeline, playback becomes driven by scroll position instead.
There are two types of scroll timelines: scroll() and view(). They solve different problems.
scroll(): The Scroll Progress Timeline
The scroll() function creates a timeline based on the total scroll progress of a scroll container. At the top of the container, the animation is at 0%. At the bottom, it is at 100%. This makes it ideal for things like reading progress bars and parallax backgrounds.
@keyframes progress {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
.progress-bar {
animation: progress linear;
animation-timeline: scroll(root block);
transform-origin: left;
}
The scroll() function accepts two optional arguments: the scroll container and the axis. The container can be root (the document), nearest (the closest scrollable ancestor), or self (when the element itself is scrollable). The axis can be block (vertical in horizontal writing modes) or inline (horizontal). These default to nearest and block respectively if you omit them.
Notice that animation-duration is not set here. When you use a scroll timeline, duration is controlled by the scroll container's range, not by time. Setting a duration would be ignored, so it is conventional to omit it entirely.
view(): The View Progress Timeline
The view() function creates a timeline based on how far an element has traveled through the viewport (or a scroll container). This is the API behind the "animate on scroll" pattern: elements that fade in, slide up, or scale as they enter the screen.
@keyframes fade-up {
from {
opacity: 0;
transform: translateY(2rem);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.card {
animation: fade-up linear both;
animation-timeline: view();
animation-range: entry 0% entry 40%;
}
The animation-range property is essential here. Without it, the animation runs across the element's entire journey through the viewport, from when the element's bottom edge first enters the viewport to when its top edge exits it. For most entrance effects, you want the animation to complete while the element is still near the top of the viewport, not continue reversing as it scrolls further up. Setting animation-range: entry 0% entry 40% limits playback to just the entry phase, which is exactly what you want.
Named Timelines with scroll-timeline and view-timeline
Both scroll() and view() are anonymous: they create a timeline scoped to the element that uses them. When you need one element's animation to be driven by a different element's scroll position, you use named timelines.
/* The scroll container declares a named timeline */
.gallery-track {
overflow-x: auto;
scroll-timeline: --gallery-progress inline;
}
/* A distant element consumes that timeline */
.gallery-dots .dot {
animation: dot-progress linear both;
animation-timeline: --gallery-progress;
}
Named timelines use custom property syntax (double dashes). The scroll-timeline shorthand sets both the name and the axis. Any element on the page can then reference that name in its own animation-timeline, as long as the element is a descendant of the scroll container in the DOM, or you use timeline-scope to promote the timeline's visibility up the tree.
When using view() timelines with animation-range, the both keyword in animation-fill-mode keeps the animation's final state applied even after the timeline range ends. Without it, elements that have animated in will snap back to their initial state as the user continues scrolling.
Browser Support for Scroll-Driven Animations
Scroll-driven animations are supported in Chrome 115 and later, and in Safari 18 and later. Firefox has had experimental support behind a flag since Firefox 110 but it is not yet enabled by default. This means you currently cannot treat scroll-driven animations as a baseline feature for all users.
The right approach is progressive enhancement: write the non-animated version first, then layer scroll animations on top using @supports.
/* Default: no animation, always visible */
.card {
opacity: 1;
transform: none;
}
/* Enhanced: scroll-driven entrance for supporting browsers */
@supports (animation-timeline: scroll()) {
.card {
opacity: 0;
animation: fade-up linear both;
animation-timeline: view();
animation-range: entry 0% entry 40%;
}
}
This pattern ensures Firefox users (and any other unsupporting browser) always see your content. Supporting browsers get the enhanced animated experience.
The View Transitions API
The View Transitions API solves a completely different problem from scroll animations. When you update the DOM (navigating between pages, switching tabs, revealing new content), the change is instantaneous. The View Transitions API lets you animate between the old state and the new state with a crossfade (or any animation you define) without writing complex snapshot or animation orchestration code yourself.
The browser handles the heavy lifting: it captures a screenshot of the current state, applies the DOM update, then animates between the two states using CSS animations you control.
Same-Document View Transitions
For transitions within a single page (a single-page app navigating between views, a gallery switching images, a settings panel revealing), you wrap your DOM update in document.startViewTransition().
/**
* Updates the active gallery image with a view transition.
* @param {string} newSrc - The image source to display
*/
const updateGalleryImage = (newSrc) => {
if (!document.startViewTransition) {
// Fallback for unsupporting browsers
document.querySelector('.gallery-hero img').src = newSrc;
return;
}
document.startViewTransition(() => {
document.querySelector('.gallery-hero img').src = newSrc;
});
};
When the browser runs this, it captures a snapshot of the current state, executes the callback (updating the DOM), then animates between the two snapshots. The default animation is a simple crossfade. This alone produces a polished result with almost no effort.
Controlling Transitions with view-transition-name
The real power comes when you assign named transition elements. By default, the entire page crossfades as a single unit. When you give an element a view-transition-name, the browser treats it as a distinct transitioning element and can animate it independently, even morphing it from one position and size to another.
/* Mark the hero image as a named transition element */
.gallery-hero img {
view-transition-name: hero-image;
}
/* Optional: customize the transition animation */
::view-transition-old(hero-image) {
animation: fade-out 0.3s ease;
}
::view-transition-new(hero-image) {
animation: fade-in 0.3s ease;
}
The ::view-transition-old() and ::view-transition-new() pseudo-elements let you target the outgoing and incoming snapshots separately. This is how you achieve effects beyond the default crossfade: slides, scales, clips, or any CSS animation you can define.
One critical constraint: view-transition-name values must be unique on the page at any given moment. If two elements share a name during a transition, the browser will ignore both. This becomes a challenge in list-based UIs, which is addressed below.
FLIP-Style Transitions with Shared Elements
One of the most impressive things the View Transitions API enables is FLIP-style animations without any JavaScript geometry calculations. If an element exists in both the old and new states with the same view-transition-name, the browser automatically animates it from its old position and size to its new position and size. This creates the effect that the element physically moved or morphed.
/* Each card gets a unique view-transition-name */
.product-card:nth-child(1) { view-transition-name: product-1; }
.product-card:nth-child(2) { view-transition-name: product-2; }
.product-card:nth-child(3) { view-transition-name: product-3; }
When JavaScript reorders or resizes these cards inside a startViewTransition() callback, each card animates smoothly from its old location to its new location. The alternative, implementing FLIP manually, requires measuring element positions before and after the DOM change and applying calculated transforms. The View Transitions API collapses that complexity into a few lines of CSS.
When working with lists where each item needs a unique name, you can set view-transition-name via JavaScript rather than hardcoding it. Use element.style.viewTransitionName = 'item-' + id before calling startViewTransition(). This gives each item a stable, unique name tied to its data, not its DOM position.
Cross-Document View Transitions (MPA Navigation)
As of 2024, View Transitions can also work across full page navigations, with no JavaScript required. This is the multi-page application (MPA) mode, enabled entirely in CSS.
@view-transition {
navigation: auto;
}
With this single rule in your stylesheet, the browser automatically applies a crossfade between pages when the user navigates. You can then use view-transition-name on elements that exist on both the origin and destination pages to create shared element transitions across a full page load.
This is a genuinely new capability. Prior to this API, smooth multi-page transitions required a single-page application architecture or a JavaScript-based navigation interception library. MPA view transitions make this possible with pure HTML and CSS.
Browser Support for View Transitions
Same-document view transitions are supported in Chrome 111, Edge 111, and Safari 18. Firefox does not yet support the View Transitions API. Cross-document view transitions are supported in Chrome 126 and Safari 18.
The fallback pattern here is simpler than for scroll animations. Because document.startViewTransition() is a JavaScript call, you check for its existence before using it. For CSS cross-document transitions, browsers that do not support them simply perform an instant page navigation. The content is still accessible, just without the animation.
/**
* Wraps a DOM update in a view transition if the API is available.
* @param {Function} updateFn - The function that updates the DOM
*/
const withViewTransition = (updateFn) => {
if (!document.startViewTransition) {
updateFn();
return;
}
document.startViewTransition(updateFn);
};
The Web Animations API
CSS animations and transitions cover most use cases, but some scenarios genuinely require JavaScript control: animations that need to pause, reverse, or respond dynamically to user input mid-animation; animations whose timing depends on data; or animations where you need to read back the current state. The Web Animations API (WAAPI) provides a JavaScript interface to the same animation engine that powers CSS animations.
Element.animate()
The primary entry point is element.animate(), which accepts keyframes and timing options and returns an Animation object.
/**
* Animates a notification element into view.
* @param {HTMLElement} el - The notification element to animate
* @returns {Animation} The running animation instance
*/
const showNotification = (el) => {
return el.animate(
[
{ opacity: 0, transform: 'translateY(-1rem)' },
{ opacity: 1, transform: 'translateY(0)' }
],
{
duration: 300,
easing: 'ease-out',
fill: 'forwards'
}
);
};
The returned Animation object exposes a finished promise, which resolves when the animation completes. This is the key advantage over CSS animations for sequenced effects: you can await an animation's completion before starting the next one.
/**
* Sequences an exit animation followed by a content swap.
* @param {HTMLElement} panel - The panel to transition out
* @param {Function} swapContent - Callback that updates the panel's content
*/
const transitionPanel = async (panel, swapContent) => {
// Wait for exit animation to finish
await panel.animate(
[{ opacity: 1 }, { opacity: 0 }],
{ duration: 200, fill: 'forwards' }
).finished;
// Update content, then animate in
swapContent();
panel.animate(
[{ opacity: 0 }, { opacity: 1 }],
{ duration: 200 }
);
};
Animation Playback Control
The Animation object gives you full playback control. This is where WAAPI genuinely outperforms CSS animations: you can pause, play, reverse, and seek animations programmatically in response to user interactions.
const anim = el.animate(keyframes, { duration: 1000, fill: 'both' });
// Pause immediately on creation if you want manual control
anim.pause();
// Seek to halfway through
anim.currentTime = 500;
// Play forward
anim.play();
// Reverse direction
anim.reverse();
// React to completion
anim.finished.then(() => {
console.log('Animation complete');
});
getAnimations() for Inspecting Running Animations
You can retrieve all animations currently running on an element (including CSS animations and transitions) using element.getAnimations(). This is useful when you need to cancel or modify existing animations before starting new ones.
/**
* Cancels any running animations on an element before starting a new one.
* @param {HTMLElement} el - The element to animate
* @param {Keyframe[]} keyframes - The animation keyframes
* @param {KeyframeAnimationOptions} options - Timing options
* @returns {Animation} The new animation instance
*/
const replaceAnimation = (el, keyframes, options) => {
el.getAnimations().forEach((anim) => { anim.cancel(); });
return el.animate(keyframes, options);
};
The Web Animations API and scroll-driven animations are not separate systems; they share the same engine. You can create a ScrollTimeline or ViewTimeline object in JavaScript and pass it as the timeline option to element.animate(). This gives you programmatic control over scroll-driven animations that goes beyond what pure CSS allows, including dynamically creating timelines and mixing scroll and time-based animations on the same element.
Choosing Between the APIs
These three APIs overlap in capability, so understanding when to use each one matters more than knowing each API's syntax in isolation.
Use scroll-driven animations when the animation's progress should be tied to a user's scroll position. Reading progress bars, parallax effects, and scroll-triggered entrance animations are the canonical use cases. Prefer the CSS approach (animation-timeline) over the JavaScript approach (ScrollTimeline) unless you need dynamic control that CSS cannot express.
Use the View Transitions API when you are updating the DOM and want to animate between the old and new states. This includes SPA page transitions, gallery image swaps, list reordering, and any time the user's mental model of the interface should feel like a physical transition rather than an instant swap. It is also the right tool for MPA (multi-page) transitions where you previously would have needed a full SPA rewrite to achieve smooth navigation.
Use the Web Animations API when you need JavaScript-level control over animations: sequencing via await animation.finished, dynamic keyframes that depend on runtime data, mid-animation reversals based on user input, or reading back an animation's current state. For simple CSS-expressible animations, prefer CSS. Reach for WAAPI when the interaction logic genuinely requires it.
All three APIs have meaningful gaps in browser support as of 2025. Scroll-driven animations lack Firefox support. The View Transitions API lacks Firefox support entirely. Always check current support on MDN or caniuse.com before committing to these APIs as a baseline feature. Use @supports for CSS features and feature detection in JavaScript for progressive enhancement.
Key Concepts Summary
Scroll-driven animations move the browser's animation engine off the main thread and tie animation progress directly to scroll position via animation-timeline: scroll() or animation-timeline: view(). The animation-range property controls which portion of the scroll journey triggers the animation. Named timelines allow one element's animation to be driven by a different element's scroll position.
The View Transitions API captures old and new DOM states and animates between them. document.startViewTransition() handles same-document transitions; @view-transition { navigation: auto; } enables cross-document transitions. The view-transition-name property identifies shared elements that should animate between states independently, enabling FLIP-style morphing without manual geometry calculations.
The Web Animations API provides JavaScript control over the same animation engine that CSS uses. element.animate() returns an Animation object with playback controls and a finished promise for sequencing. It integrates with scroll-driven animations via ScrollTimeline and ViewTimeline objects, making it the right tool when CSS alone cannot express the interaction logic you need.