Animations via JavaScript
|
This section documents modern ECMAScript and core browser JavaScript APIs — it is not tied to any specific framework or library (React, Vue, Angular, etc.). This content was generated with the assistance of AI and should be verified against the current ECMAScript specification and MDN documentation before relying on it in production, since JavaScript language features and browser API support continue to evolve. This section’s bibliography lists the reference material consulted while preparing these pages. |
The reference book has only light coverage of this topic — a single subsection (§15.4.5, on reacting to CSS
transition/animation events from JavaScript) — so most of this page draws on general/MDN knowledge instead. That
narrow book subsection is already covered in full on
Accessing CSS from JavaScript (see "Reacting to Animation and Transition
Events" there); this page instead covers the broader landscape of driving animation from JavaScript: toggling
CSS state, the requestAnimationFrame loop, the Web Animations API, and animating SVG.
Driving CSS Animations and Transitions from JavaScript
The simplest way for JavaScript to animate something is to not animate it directly at all — instead, change a
small piece of state and let a CSS transition or @keyframes animation (see
Transitions and Animations & Keyframes)
interpolate the visual change. The browser’s compositor drives the frames, which keeps the animation smooth even
if the main JavaScript thread is briefly busy.
Toggling classes
Adding or removing a class is the most common trigger, using the classList API covered in
Accessing CSS from JavaScript:
// .card { transition: transform .2s ease-out; }
// .card.raised { transform: translateY(-4px) scale(1.02); }
card.classList.add("raised"); // starts the transition
card.classList.remove("raised"); // reverses it
This keeps the animation’s actual definition (durations, easing, keyframes) in the stylesheet, and the
JavaScript declarative — it says what state the element is in, not how to get there frame by frame. Listen
for transitionend/animationend (also covered on that page) to know when the animation has actually finished,
e.g. before removing the element from the DOM or re-enabling a button.
Toggling custom properties
A CSS custom property (variable) can parameterize an animation or transition without needing a different class for every possible value. This is especially useful when the target value is computed at runtime rather than being one of a small fixed set:
.progress-bar {
--progress: 0;
width: calc(var(--progress) * 1%);
transition: width 0.3s ease-out;
}
function setProgress(bar, percent) {
bar.style.setProperty("--progress", percent); // triggers the CSS transition on `width`
}
setProgress(progressBar, 75);
A custom property is not itself animatable by default — only the concrete property that consumes it (width
above) actually transitions, unless --progress is registered with @property (declaring a syntax and
initial-value), in which case the custom property itself can be transitioned/animated directly.
The requestAnimationFrame Loop
Class and custom-property toggling only goes so far: it can’t express an animation whose path depends on
continuous input (dragging, physics, a value that changes every frame based on other state). For that,
JavaScript drives the animation itself, one frame at a time, using window.requestAnimationFrame().
requestAnimationFrame(callback) asks the browser to call callback right before its next repaint — typically 60 times per second, matching the display’s refresh rate, and paused automatically while the tab is in
a background/hidden state. callback receives a single argument: a high-resolution timestamp (milliseconds,
same clock as performance.now()) representing the moment the frame started. Each call schedules only one
future frame, so a continuous animation must call requestAnimationFrame again from inside the callback itself.
Worked example: animating a value over time
The key to smooth, frame-rate-independent motion is using the timestamp argument to compute elapsed time, rather than assuming a fixed amount of progress happens on every call — a monitor running at 144Hz calls the callback far more often than one running at 60Hz, and a slow frame (a GC pause, a busy tab) must not cause the animation to visibly jump:
function animateTo(element, toX, duration = 400) {
const fromX = parseFloat(getComputedStyle(element).translate) || 0;
const delta = toX - fromX;
let startTime = null;
let frameId;
function step(timestamp) {
if (startTime === null) startTime = timestamp;
const elapsed = timestamp - startTime;
const progress = Math.min(elapsed / duration, 1); // clamp to [0, 1]
const eased = 1 - Math.pow(1 - progress, 3); // ease-out cubic
element.style.translate = `${fromX + delta * eased}px`;
if (progress < 1) {
frameId = requestAnimationFrame(step);
}
}
frameId = requestAnimationFrame(step);
return () => cancelAnimationFrame(frameId); // caller can cancel mid-flight
}
const cancel = animateTo(document.querySelector("#puck"), 300);
// cancel(); // stop the animation before it completes, e.g. on a new user interaction
A few points worth calling out:
-
Never accumulate a fixed step per frame (
x += 2inside the callback) — that ties speed to the display’s refresh rate. Always derive progress from the elapsed time, as above, so the animation takes the same duration on a 60Hz and a 144Hz display alike. -
cancelAnimationFrame(id), given the ID returned by the matchingrequestAnimationFramecall, cancels a frame that hasn’t fired yet — essential when the same element might be re-animated before a previous animation finishes, or when the element is removed from the DOM mid-animation. -
A
requestAnimationFrameloop is also the standard way to drive per-frame drawing on<canvas>— see Canvas, WebGL & Three.js for that render-loop pattern. -
Respect the user’s
prefers-reduced-motionsetting (matchMedia("(prefers-reduced-motion: reduce)").matches) by skipping or shortening non-essential JavaScript-driven animation, the same way a CSS animation should.
The Web Animations API
The Web Animations API is a native, imperative alternative to both CSS-class toggling and hand-rolled
requestAnimationFrame loops: it lets JavaScript describe a keyframe animation directly, while still letting the
browser’s own compositor run and interpolate it (no per-frame JavaScript callback required).
element.animate()
Every Element exposes animate(keyframes, options), which starts the animation immediately and returns an
Animation object representing it:
const banner = document.querySelector("#banner");
const animation = banner.animate(
[
{ transform: "translateY(-100%)", opacity: 0 }, // implicit offset 0
{ transform: "translateY(0)", opacity: 1 }, // implicit offset 1
],
{
duration: 400,
easing: "ease-out",
fill: "forwards", // keep the end-state styling applied once the animation finishes
},
);
Keyframes can be given as an array of state objects (as above, evenly spaced by default, or explicitly
positioned with an offset field between 0 and 1 on each), or equivalently as a single object whose
properties are each an array of values:
banner.animate(
{ transform: ["translateY(-100%)", "translateY(0)"], opacity: [0, 1] },
{ duration: 400, easing: "ease-out", fill: "forwards" },
);
Common options fields:
| Option | Meaning |
|---|---|
|
Length of one iteration, in milliseconds. |
|
A CSS easing function ( |
|
Number of times to repeat; |
|
|
|
Milliseconds to wait before starting / after finishing before considering the animation done. |
|
Whether the first/last keyframe’s styling applies outside the animation’s active interval: |
Controlling the returned Animation object
Unlike a CSS class toggle, the object returned by animate() can be paused, resumed, reversed, or inspected at
any point — there is no need to separately track "is this element mid-animation":
const spin = logo.animate(
[{ transform: "rotate(0deg)" }, { transform: "rotate(360deg)" }],
{ duration: 1200, iterations: Infinity },
);
spin.pause(); // freeze on the current frame
spin.play(); // resume from where it was paused
spin.playbackRate = 2; // play at double speed (negative values play backwards)
spin.reverse(); // swap direction, continuing from the current position
spin.cancel(); // stop and remove all animation effects, reverting to pre-animation styling
// spin.finish(); // jump immediately to the end -- throws `InvalidStateError` here, since
// `finish()` requires a finite animation and `spin` uses `iterations: Infinity`;
// use `cancel()` above to stop an infinite animation instead
// `finished` is a Promise that resolves once the animation completes (or rejects if cancelled)
spin.finished.then(() => console.log("spin finished"));
spin.onfinish = () => console.log("spin finished"); // equivalent event-based alternative
currentTime (milliseconds into the animation) and playState ("idle", "running", "paused", or
"finished") can both be read and, for currentTime, written to scrub the animation directly — handy for
tying an animation’s progress to something else, such as a scroll position or a drag gesture.
Choosing between the three approaches
| Approach | Best for | Drawback |
|---|---|---|
CSS class/custom-property toggle |
Fixed, stylesheet-defined states and transitions; the common case |
Only expresses states known ahead of time; JS can’t query fine-grained progress |
|
Continuous, input-driven, or physically-simulated motion; canvas drawing |
Runs on the main thread every frame; more code to get right (easing, cancellation) |
Web Animations API ( |
Keyframe animations that need imperative control (pause/reverse/scrub) without a per-frame callback |
Keyframes are still declared in JS, not the stylesheet, so designers can’t tweak them independently |
The three can also be mixed — e.g. element.animate() for a visual effect while a requestAnimationFrame loop
separately drives an unrelated physics calculation.
Animating SVG from JavaScript
Inline SVG shapes (see Styling & Animating SVGs for the CSS-only
approach, including the stroke-dashoffset line-draw technique and the legacy SMIL syntax) can be animated with
either of the JavaScript techniques above, since an inlined SVG element is a normal part of the page’s DOM:
const circle = document.querySelector(".icon-check__circle");
const length = circle.getTotalLength(); // exact path length, no manual measuring needed
circle.style.strokeDasharray = `${length}`;
circle.style.strokeDashoffset = `${length}`;
circle.animate(
[{ strokeDashoffset: length }, { strokeDashoffset: 0 }],
{ duration: 600, easing: "ease-out", fill: "forwards" },
);
A few SVG-specific notes when animating this way:
-
getTotalLength()(available on<path>,<circle>,<rect>, and other shape elements) removes the need to measure or guess a path’s length up front, which the pure-CSS technique on Styling & Animating SVGs has to do manually. -
CSS-animatable SVG properties (
stroke-dashoffset,fill,opacity, and geometry properties likecx/cy/rin current evergreen browsers) work withelement.animate()exactly as they do withclassListtoggling or a CSS@keyframesrule — the Web Animations API simply triggers the same underlying animation machinery. -
Prefer setting SVG-specific values through the CSS property rather than the matching XML attribute when animating from JavaScript, for the cascade/specificity reasons discussed on that page.
-
For animating an SVG’s
transform, remembertransform-box: fill-boxmay be needed sotransform-origincenters on the shape itself rather than the SVG viewport’s origin — also covered on that page.
For anything beyond simple shape/property animation — large numbers of independently moving elements, particle
effects, or pixel-level manipulation — rendering to <canvas> instead of the DOM is usually a better fit; see
Canvas, WebGL & Three.js.