Animation and Scroll APIs in JavaScript: element.animate, getAnimations, the finished Promise, scrollIntoView and scrollend
In JavaScript you start an animation with element.animate, control it through the returned Animation object and wait for its end with the finished promise; for scrolling you reach the target with scrollIntoView and scrollBy and do the work once scrolling ends with scrollend. We show it in Bookstore with a cover flying to the cart, jumping to the invalid checkout field and the book strip's arrows, with real browser output.
Version note: The code in this post targets ES2026 (ECMAScript 2026) and current browsers (Chrome, Edge, Firefox, Safari). The examples were run in the Chromium 158 and WebKit engines of Playwright 1.65, on a Bookstore page served on 127.0.0.1.
Predict First
The home page of Bookstore, the online bookstore used as the shared example across this site's series, has a sale banner. We slide it 200 pixels to the right with JavaScript. Where does the banner stop when the animation ends, and what does the console print?
const banner = document.querySelector(".sale-banner");
const slide = banner.animate(
[{ transform: "translateX(0)" }, { transform: "translateX(200px)" }],
{ duration: 500 }
);
console.log(slide.playState);
slide.finished.then(() => {
console.log(slide.playState, getComputedStyle(banner).transform);
});
It prints running first and finished none half a second later. The banner travels 200 pixels and jumps back to where it started the moment the animation ends. This isn't a bug: element.animate doesn't touch the element's style, it only adds a layer while the animation runs. When the animation ends the layer goes away and the element's own style comes back. In this post we'll see how to manage that behavior, how to tell when an animation has ended and how to scroll the page with JavaScript.
element.animate and the Animation Object
element.animate(keyframes, options) is the entry point to the Web Animations API. You can think of it as the JavaScript counterpart of @keyframes from the CSS3 series post on transitions and animations; the difference is that you can compute values at run time and you hold the animation as an object. According to the web-features explorer, this API has been in every major browser since September 2020 and widely available since March 2023.
const duneCover = document.querySelector("#book-dune .cover");
const wiggle = duneCover.animate(
[
{ transform: "rotate(0deg)" },
{ transform: "rotate(-6deg)", offset: 0.25 },
{ transform: "rotate(6deg)", offset: 0.75 },
{ transform: "rotate(0deg)" }
],
{ duration: 400, iterations: 3, easing: "ease-in-out", delay: 100 }
);
console.log(wiggle.playState); // running
Keyframes are an array; if you don't give an offset, they're spread at equal intervals. For short single-property animations you can also write an object like { opacity: [0, 1] }, and if the second argument is just a number it's taken as the duration. The animation above takes 1300 ms in total: a 100 ms delay and three 400 ms iterations.
The Animation object returned by animate works like a player. You can pause it, seek to a given moment, speed it up or cancel it completely:
const emmaCover = document.querySelector("#book-emma .cover");
const spin = emmaCover.animate(
{ transform: ["rotate(0deg)", "rotate(360deg)"] },
{ duration: 1000, iterations: Infinity }
);
spin.pause();
spin.currentTime = 250;
console.log(spin.playState, getComputedStyle(emmaCover).transform); // paused matrix(0, 1, -1, 0, 0, 0)
spin.playbackRate = 2;
spin.play();
console.log(spin.playState); // running
spin.reverse();
spin.cancel();
console.log(spin.playState, getComputedStyle(emmaCover).transform); // idle none
currentTime = 250 is a quarter of the one-second turn, that is 90 degrees; matrix(0, 1, -1, 0, 0, 0) is exactly a 90-degree rotation. reverse flips the direction, and cancel removes the animation and its effect entirely.
Reading prefers-reduced-motion in JavaScript
Some users turn on the "reduce motion" setting in their operating system; motion bothers them or makes them dizzy. In CSS you handle this with @media (prefers-reduced-motion: reduce). Animations you start with JavaScript aren't affected by that media query; you have to read the preference yourself:
const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)");
const motionOk = () => !reducedMotion.matches;
console.log(reducedMotion.matches); // false
reducedMotion.addEventListener("change", () => {
console.log("reduce motion:", reducedMotion.matches);
});
Don't read matches once and copy it into a variable; the user can change the setting while the page is open. When I changed the setting in Playwright with the page open, the change event arrived and the console printed reduce motion: true. From here on we'll check motionOk() before every animation.
The finished Promise: an "Add to Cart" Animation
When the customer presses "Add to cart", the book cover should shrink and fly to the cart icon. We measure the positions of the cover and the icon with getBoundingClientRect, which we saw in the Intermediate series post on advanced DOM, put a copy of the cover at the same spot with position: fixed and animate the copy:
async function flyToCart(button) {
if (!motionOk()) return;
const cover = button.closest(".book").querySelector(".cover");
const from = cover.getBoundingClientRect();
const to = document.querySelector("#cart-icon").getBoundingClientRect();
const ghost = cover.cloneNode(true);
Object.assign(ghost.style, {
position: "fixed", left: `${from.left}px`, top: `${from.top}px`,
width: `${from.width}px`, height: `${from.height}px`, margin: "0", pointerEvents: "none"
});
document.body.append(ghost);
const dx = to.left + to.width / 2 - (from.left + from.width / 2);
const dy = to.top + to.height / 2 - (from.top + from.height / 2);
const flight = ghost.animate(
[
{ transform: "translate(0, 0) scale(1)", opacity: 1 },
{ transform: `translate(${dx}px, ${dy}px) scale(0.1)`, opacity: 0.5 }
],
{ duration: 600, easing: "ease-in" }
);
try {
await flight.finished;
} catch (err) {
if (err.name !== "AbortError") throw err; // a cancel()ed animation rejects with AbortError
} finally {
ghost.remove();
}
}
finished is a promise that resolves when the animation ends. Only then do we remove the copy; if we had written remove right after animate, the user would have seen nothing. If the animation is cancelled with cancel, the promise rejects with an AbortError. If you don't catch it, the page gets an unhandled promise rejection; I saw "The user aborted a request." in Chromium and "Unhandled Promise Rejection: AbortError" in WebKit. finally cleans up the copy in both cases. With reduced motion, the function returns without animating at all.
There's an important decision on the button side: don't tie adding to the cart to the animation. The book goes into the cart right away; the animation is only visual feedback:
const cart = [];
const cartCount = document.querySelector("#cart-count");
document.querySelector(".shelf").addEventListener("click", async (event) => {
const button = event.target.closest(".add-to-cart");
if (!button) return;
cart.push(button.closest(".book").querySelector(".cover").textContent); // state first, independent of the animation
await flyToCart(button);
cartCount.textContent = cart.length;
if (motionOk()) cartCount.animate([{ transform: "scale(1.5)" }, { transform: "scale(1)" }], 200);
console.log("in cart:", cart.length, "ghosts left:", document.querySelectorAll("body > .cover").length);
});
When I clicked Dune's button, both engines printed in cart: 1 ghosts left: 0 after 600 ms; mid-flight, at 300 ms, there was a single copy on the page. Then I pressed Emma's button twice in quick succession: two copies flew at the same time, and the console printed in cart: 3 ghosts left: 1 when the first landed and in cart: 3 ghosts left: 0 when the second landed. The cart count is 3 in both, because the state was updated at click time. With reduced motion turned on, clicking The Hobbit created no copy and the badge became 4 immediately.
fill and commitStyles
The first way to stop the banner from jumping back is fill: "forwards": after the animation ends, the last keyframe keeps applying. But it comes at a price; let's hide the sale banner and then try to show it again:
const hide = banner.animate({ opacity: [1, 0] }, { duration: 300, fill: "forwards" });
await hide.finished;
console.log(getComputedStyle(banner).opacity); // 0
banner.style.opacity = "1";
console.log(getComputedStyle(banner).opacity); // 0: the fill still wins
hide.commitStyles();
hide.cancel();
console.log(banner.style.opacity); // 0: the value is now in the style attribute
banner.style.opacity = "1";
console.log(getComputedStyle(banner).opacity); // 1
An animation that keeps filling wins over the element's own style; changing style.opacity doesn't help. commitStyles writes the animation's current value into the element's style attribute; once you remove the animation with cancel, the element is styled normally again. MDN says commitStyles also works without fill in newer browsers, but since there's no feature detection for that, it recommends you keep writing fill.
If filling animations on the same property of the same element pile up, the browser removes the old ones itself:
const hobbitCover = document.querySelector("#book-hobbit .cover");
const steps = [0, 1, 2].map((i) =>
hobbitCover.animate(
{ transform: [`translateX(${i * 10}px)`, `translateX(${i * 10 + 10}px)`] },
{ duration: 50, fill: "forwards" }
)
);
await steps[2].finished;
console.log(steps.map((a) => a.replaceState), hobbitCover.getAnimations().length);
// ["removed", "removed", "active"] 1
steps[2].cancel();
The two animations underneath became removed. Thanks to this, code that adds a new fill animation on every click doesn't pile up thousands of animations in memory. If you want to keep one, call persist().
Managing CSS Animations with getAnimations
getAnimations returns not only the animations you started with JavaScript but also CSS animations and transitions, as Animation objects. The "Sale" badge on the page keeps growing and shrinking with @keyframes pulse in CSS:
const running = document.getAnimations();
console.log(running.map((a) => `${a.constructor.name} ${a.animationName}`)); // ["CSSAnimation pulse"]
const header = document.querySelector(".site-header");
console.log(header.getAnimations().length, header.getAnimations({ subtree: true }).length); // 0 1
running.forEach((a) => a.pause());
The badge sits inside the header, so the header's own list is empty; subtree: true includes descendants as well. A CSS transition that starts when you hover over a cover shows up in the list as a CSSTransition with a transitionProperty of transform; I saw that in both engines by moving the mouse over a cover. You use this list to stop all motion behind a modal while it opens, to finish animations in a test, or to wait for the finished of a CSS animation. On an infinitely repeating animation, finish() throws an InvalidStateError; stop those with pause or cancel.
Going to the Invalid Field with scrollIntoView
When the checkout form is submitted with a missing field, we want to take the customer to that field. The form has novalidate, so we turned off the browser's own warning bubble and do the job ourselves:
const checkoutForm = document.querySelector("#checkout");
function focusFirstError(form) {
const field = form.querySelector(":invalid");
if (!field) return null;
field.scrollIntoView({ block: "center", behavior: motionOk() ? "smooth" : "instant" });
field.focus({ preventScroll: true });
return field;
}
checkoutForm.addEventListener("submit", (event) => {
if (checkoutForm.checkValidity()) return;
event.preventDefault();
const field = focusFirstError(checkoutForm);
console.log("first error:", field.id, "focused:", document.activeElement === field);
});
block picks the vertical alignment: "start" (the default), "center", "end" and "nearest". "nearest" doesn't scroll at all if the element is already visible. inline does the same horizontally, with "nearest" as the default. We passed preventScroll: true to focus; without it, focus would jump the page to the field instantly and spoil the smooth scroll. When I filled in the name and left the e-mail empty, the console printed first error: email focused: true. When the scroll ended, the field's top edge was at pixel 376: in the 700-pixel viewport, what gets centered isn't the field alone but the field together with the 72-pixel scroll-margin above it. That margin is defined in the page's CSS; we look at it right below.
If there's a sticky header at the top of the page, block: "start" hides the field under it. The fix is in CSS: scroll-margin-top, as covered in the CSS Properties series post on overflow and scroll behavior, sets the space the target keeps when it's aligned:
#checkout :is(input, textarea) { scroll-margin-top: 72px; } /* 56 px header + 16 px gap */
const postalCode = document.querySelector("#postal-code");
postalCode.scrollIntoView({ block: "start", behavior: "instant" });
console.log(Math.round(postalCode.getBoundingClientRect().top)); // 72
postalCode.style.scrollMarginTop = "0px";
postalCode.scrollIntoView({ block: "start", behavior: "instant" });
console.log(Math.round(postalCode.getBoundingClientRect().top)); // 0: hidden under the header
The other two scrolling tools are scrollTo and scrollBy: the first scrolls to an absolute position, the second relative to where you are. For a "Back to top" button, window.scrollTo({ top: 0, behavior: "smooth" }) is enough. behavior: "auto" follows the scroll-behavior value in CSS.
Doing Work When Scrolling Ends with scrollend
The "Bestsellers" strip on the home page is a horizontally scrolling list with arrow buttons on both sides. When the strip reaches the start, the left arrow should be disabled; when it reaches the end, the right arrow. We want to do this once, when scrolling ends, not on every scroll event:
const shelf = document.querySelector(".shelf");
const prevButton = document.querySelector("#shelf-prev");
const nextButton = document.querySelector("#shelf-next");
function updateArrows() {
const maxScroll = shelf.scrollWidth - shelf.clientWidth;
prevButton.disabled = shelf.scrollLeft <= 0;
nextButton.disabled = shelf.scrollLeft >= maxScroll - 1;
console.log("scrollLeft:", shelf.scrollLeft, "prev:", !prevButton.disabled, "next:", !nextButton.disabled);
}
function scrollShelf(direction) {
shelf.scrollBy({ left: direction * shelf.clientWidth, behavior: motionOk() ? "smooth" : "instant" });
}
prevButton.addEventListener("click", () => scrollShelf(-1));
nextButton.addEventListener("click", () => scrollShelf(1));
shelf.addEventListener("scrollend", updateArrows);
updateArrows(); // scrollLeft: 0 prev: false next: true
When I pressed the right arrow, a single line appeared in the console: scrollLeft: 560 prev: true next: false. The strip is 600 pixels wide and its content 1160 pixels; we asked for 600 pixels, and the browser could go no further than 560. During that one smooth scroll, Chromium fired 23 scroll events and WebKit 14 or 15, depending on the run; scrollend arrived once in both. If the position doesn't change at all, scrollend doesn't fire; pressing the left arrow while the strip was at the start printed nothing. According to MDN, it also doesn't fire while a finger is still on a touch screen, only after the user lets go.
According to the web-features explorer, scrollend became Baseline in December 2025 with Safari 26.2; Chrome has supported it since 114 and Firefox since 109. If you also support older Safari versions, you can check for the event and fall back to the debounce from the performance post:
if (!("onscrollend" in window)) {
let scrollTimer;
shelf.addEventListener("scroll", () => {
clearTimeout(scrollTimer);
scrollTimer = setTimeout(updateArrows, 150);
}, { passive: true });
}
The debounce runs 150 ms after the last scroll event; it can also fire in the middle of a slow scroll, and it's always a little late. scrollend is the browser's own "scrolling is done" decision. One more note: in Chromium 158, scrollIntoView and scrollBy now return a promise that resolves when scrolling ends (the result is { interrupted: false }); in WebKit the same call returned undefined. This isn't Baseline yet; for the end of a scroll, rely on scrollend today.
Production Notes
What you animate matters more than how you animate it. We saw layout thrashing in the performance post; animating properties like width, height, top and left makes the browser recompute layout on every frame. I shrank the same cover for one second, first with width, then with transform:
const middlemarchCover = document.querySelector("#book-middlemarch .cover");
await middlemarchCover.animate({ width: ["180px", "120px"] }, 1000).finished;
await middlemarchCover.animate({ transform: ["scale(1)", "scale(0.66)"] }, 1000).finished;
According to the performance counters in Chromium's DevTools Protocol, the layout count went up by 62 during the width animation, one per frame, and by only 2 during the transform animation. transform and opacity don't touch layout, and the browser can draw them independently of the main thread. That's why the flying cover uses transform: translate instead of left/top. We called getBoundingClientRect once before the animation to measure the position; measuring and writing in a loop takes you back to thrashing. We saw how the browser draws a frame in the rendering step of the event loop post.
CSS or JavaScript? Keep animations with a known state, like hover, drop-down menus and page transitions, in CSS; the CSS3 series posts on transitions and animations and on scroll-driven animations cover them. Choose JavaScript when values are computed at run time (the flying cover's target), when animations must run in sequence (one after another with await), or when they must be cancellable or awaited. You only need to draw every frame yourself with requestAnimationFrame when something is computed per frame (physics, canvas).
If you listen to scroll, add the listener with { passive: true } and don't do heavy work in it; to find out whether an element is visible, IntersectionObserver from the Observer APIs post is the better tool.
Common Pitfalls
Removing an element from the DOM before its animation ends. In the code below the animation completes in the background, but the user never sees the fade:
const notice = document.querySelector(".sale-banner");
const fadeOut = notice.animate({ opacity: [1, 0] }, 300);
notice.remove(); // wrong: await fadeOut.finished first, then remove
await fadeOut.finished;
console.log(fadeOut.playState, notice.isConnected); // finished false
In both engines the promise resolved with finished after about 300 ms; so the code doesn't throw, nothing just happens on screen. The right way is to move remove after the await.
Animating properties that trigger layout. Use transform and opacity instead of width, top or margin.
Ignoring the reduced motion preference. The @media rule in CSS doesn't stop JavaScript animations; put a check like motionOk() before every animate and every behavior: "smooth".
Doing heavy work in the scroll event. A single smooth scroll fires dozens of events. Use scrollend for work tied to the end of scrolling and IntersectionObserver for visibility.
Awaiting the finished of an infinite animation. The promise of an animation with iterations: Infinity never resolves; code that awaits it waits forever.
A Small Experiment
★☆☆ Slide the sale banner in from the top when the page opens (translateY(-100%) → translateY(0)) without it jumping back when the animation ends. Do it once with fill: "forwards" and once with commitStyles, and compare the getAnimations().length values.
★★☆ When a book is removed from the cart, make its row first fade out and then collapse its height: run the two animations in sequence with await and remove the row from the DOM once both have finished. With reduced motion, remove the row without animation.
★★★ Add keyboard support to the "Bestsellers" strip: while the strip has focus, the right and left arrow keys should scroll by one book card, and when scrolling ends (scrollend), focus the "Add to cart" button of the first visible card with preventScroll: true. Also add the debounce fallback for browsers without scrollend and count how many times updateArrows is called on each path.
That's the end of the Advanced series. From the prototype chain to the event loop, from memory management to Web Workers, from Web Components to Temporal and, last, animation and scroll APIs, you've seen the inner workings of the browser and the language. JavaScript grows with a new edition every year; whenever you come across a new feature, make it a habit to check its TC39 stage first, then its Baseline status.