CSS Animation vs the Web Animations API: A Decision Guide

Choosing between declarative CSS and the imperative Web Animations API (WAAPI, exposed through element.animate()) is one of the recurring decisions in motion work, and it sits at the centre of the broader CSS-Only Micro-Interactions & Animations guide. Both run on the same underlying timing model defined by the Web Animations specification, so the question is rarely "which is faster" and almost always "which gives me the control I need without paying for control I do not". This guide maps the decision across the axes that actually matter in production: where the animated values come from, how sequences are coordinated, whether you must interrupt or reverse motion, and where each approach runs in the rendering pipeline. The foundations build directly on CSS Transition Fundamentals and the declarative timeline mapping covered in Keyframe Animation Patterns; WAAPI is best understood as the imperative counterpart to those declarative tools, not a replacement for them.

What this guide establishes:

  • A repeatable rule for choosing declarative CSS vs imperative WAAPI
  • How dynamic, runtime-computed values force an imperative approach
  • Sequencing, cancellation, and reversal differences between the two
  • Why both can be GPU-composited and what that means for "performance"
CSS vs Web Animations API decision matrix A matrix scoring CSS animations against the Web Animations API across dynamic values, sequencing, runtime control, authoring cost, and compositing. Pick by capability, not by speed CSS WAAPI Author-time values strong ok Runtime values weak strong Interrupt / reverse weak strong Authoring cost low higher GPU compositing yes yes Rule Default to CSS. Reach for WAAPI when values or control move to runtime. Both share one timing model; the split is control, not throughput.

Prerequisites

This guide assumes you are comfortable with the following before deciding between the two approaches:

  • The transition and animation shorthands and their longhand properties.
  • @keyframes syntax and how keyframe offsets map to a timeline.
  • Timing functions (ease, cubic-bezier(), steps()) and animation-fill-mode.
  • Basic DOM scripting: selecting an element and calling a method on it.
  • Which properties composite (transform, opacity, filter) versus which trigger layout.

If any of those feel shaky, work through the transitions and keyframes guides linked above first, because the WAAPI uses identical concepts expressed as JavaScript objects.


Core concept: one model, two authoring surfaces

The single most important fact is that CSS animations, CSS transitions, and element.animate() are all defined against the Web Animations model. The Web Animations specification describes a timeline, an animation that maps a time to an effect, and a keyframe effect that interpolates property values. CSS @keyframes and the animation properties are a declarative serialisation of that model; the WAAPI is an imperative API over the very same machinery. A CSS animation you wrote in a stylesheet appears as an Animation object in JavaScript — element.getAnimations() returns it, and you can pause or cancel it. They are not parallel systems competing for the engine; they are two doors into one room.

That has a direct consequence for how you decide. Because the engine is shared, neither approach is meaningfully faster than the other for the same effect. A transform animation declared in CSS and the identical animation created with element.animate() are both eligible to run on the compositor thread, off the main thread, with no per-frame JavaScript. The genuine difference is who computes the values and who holds the controls. CSS holds the values at author time in the stylesheet and hands control to the cascade and pseudo-classes. WAAPI lets JavaScript compute the values at runtime and keep a handle on the running animation. The decision is therefore about the source of values and the need for control, never about raw throughput.

The corollary: prefer the smallest tool that expresses the effect. If the keyframes are static and the trigger is a state, CSS wins on simplicity, resilience, and the fact that it keeps working when scripts fail to load. The moment a value can only be known at runtime, or you must steer the animation while it plays, the declarative surface stops being expressive enough and you cross over to the imperative one.


Syntax and parameters

The two surfaces accept the same conceptual inputs. This table maps the CSS token to its WAAPI counterpart so you can read one as the other.

Concept (CSS token)Accepted valuesDefaultWAAPI equivalent
@keyframes name { … }offset blocks with property valueskeyframes array: [{ opacity: 0 }, { opacity: 1 }]
animation-duration<time> (e.g. 0.4s, 400ms)0sduration in options (ms)
animation-timing-functionease | linear | cubic-bezier() | steps()easeeasing string
animation-delay<time>0sdelay (ms)
animation-iteration-count<number> | infinite1iterations (Infinity)
animation-directionnormal | reverse | alternate | alternate-reversenormaldirection
animation-fill-modenone | forwards | backwards | bothnonefill

A cubic-bezier(0.2, 0.8, 0.2, 1) curve written in CSS is the literal string you pass as easing in WAAPI, and a duration of 0.4s becomes 400. Because the vocabularies line up, porting a tested CSS animation to JavaScript is mechanical, which is exactly why you should only do it when you need the imperative powers below.


Step-by-step: the same effect, both ways

Step 1 — Express the static case in CSS

When the target value is known up front, CSS is the whole solution. A fade-and-rise on entry needs no script.

.toast {
  opacity: 0;
  transform: translateY(8px);
  animation: rise 0.32s cubic-bezier(0.2, 0.8, 0.2, 1) forwards;
}

@keyframes rise {
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

Step 2 — Recreate it with element.animate()

The identical motion in WAAPI returns an Animation object. Note the matching durations and easing.

<div id="toast">Saved</div>
const toast = document.getElementById("toast");

const anim = toast.animate(
  [
    { opacity: 0, transform: "translateY(8px)" },
    { opacity: 1, transform: "translateY(0)" },
  ],
  { duration: 320, easing: "cubic-bezier(0.2, 0.8, 0.2, 1)", fill: "forwards" }
);

At this point the two are equivalent and the CSS version is preferable: less code, no script dependency. WAAPI earns its place only when you add the next step.

Step 3 — Inject a runtime value CSS cannot know

Suppose the toast must rise from wherever a button sits, a distance only measurable at runtime. CSS cannot read layout geometry; WAAPI can interpolate the measured number.

const button = document.querySelector(".trigger");
const start = button.getBoundingClientRect().top - toast.getBoundingClientRect().top;

toast.animate(
  [
    { opacity: 0, transform: `translateY(${start}px)` },
    { opacity: 1, transform: "translateY(0)" },
  ],
  { duration: 320, easing: "cubic-bezier(0.2, 0.8, 0.2, 1)", fill: "forwards" }
);

This is the dividing line: the start offset is a measured number, so the declarative surface can no longer express it without manually writing inline styles per element. (CSS custom properties can carry a value you already have, but they cannot run getBoundingClientRect().)

Step 4 — Take control of the running animation

The second power CSS lacks is mid-flight control. With the Animation handle you can pause, set time, reverse, and await completion.

const a = toast.animate(keyframes, { duration: 320, fill: "forwards" });

// Pause and scrub
a.pause();
a.currentTime = 160; // jump to the midpoint

// Reverse from the current position without a jump
a.reverse();

// Cancel and remove all effect
a.cancel();

// React to completion (a real promise, not an event listener)
a.finished.then(() => toast.remove());

A CSS animation toggled by class swaps cannot reverse from its current position; it restarts or snaps. The finished promise also replaces brittle animationend listeners, which fire per-iteration and per-property and are easy to mis-handle.


Annotated production example: an interruptible accordion

A common real case that exposes the boundary is an accordion whose panels can be clicked again before the previous animation finishes. CSS height transitions cannot smoothly reverse mid-flight; WAAPI can, by reading the live currentTime. The markup and resting styles stay declarative; only the interruption logic is imperative.

<button class="acc__head" aria-expanded="false" aria-controls="p1">Details</button>
<div class="acc__panel" id="p1" hidden>
  <p>Panel content that animates open and closed.</p>
</div>
.acc__panel {
  overflow: hidden;
  /* Resting presentation stays in CSS; JS only drives the height tween. */
}

@media (prefers-reduced-motion: reduce) {
  /* Honour the OS setting: the script below checks this too. */
  .acc__panel { transition: none; }
}
const head = document.querySelector(".acc__head");
const panel = document.getElementById("p1");
let current = null; // the in-flight Animation, if any

const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;

function toggle() {
  const open = head.getAttribute("aria-expanded") === "true";
  head.setAttribute("aria-expanded", String(!open));

  // Measure the real content height at runtime — CSS cannot do this.
  panel.hidden = false;
  const full = panel.scrollHeight;
  const from = open ? full : 0;
  const to = open ? 0 : full;

  // If an animation is already running, start from where it is so the
  // motion never jumps — this is the capability CSS lacks.
  if (current) current.cancel();

  current = panel.animate(
    [{ height: `${from}px` }, { height: `${to}px` }],
    {
      duration: reduce ? 0 : 280,
      easing: "cubic-bezier(0.2, 0.8, 0.2, 1)",
      fill: "forwards",
    }
  );

  current.finished.then(() => {
    if (to === 0) panel.hidden = true; // remove from a11y tree when closed
    current = null;
  });
}

head.addEventListener("click", toggle);

Everything that can stay in CSS — focus rings, colours, spacing, the reduced-motion guard — does. The script is confined to the one thing CSS cannot express: re-targeting an animation from its live position. That separation keeps the component resilient if the script fails: the panel still opens because hidden is toggled and the resting styles render.


Performance and accessibility notes

On performance, the headline is that WAAPI is not a performance technique. An element.animate() call that animates transform or opacity is composited on the same thread as the equivalent CSS animation, so both can hit 60fps without touching the main thread between frames. Animating height, as the accordion does, is a layout-triggering property in either approach and is the genuine cost there — the WAAPI does not make height cheap; it only makes the interruption possible. If you find yourself reaching for WAAPI hoping for speed, you have the wrong motivation; reach for it for control. Keep will-change: transform scoped to active states and drop it afterwards, exactly as you would for CSS, since the rules for compositor promotion are identical.

On accessibility, both surfaces must honour prefers-reduced-motion. In CSS you guard with @media (prefers-reduced-motion: reduce); in WAAPI you read matchMedia("(prefers-reduced-motion: reduce)").matches and collapse the duration to 0, as the example does. A subtle WAAPI advantage is that you can listen to the media query changing and re-plan in-flight animations, whereas CSS re-evaluates only on the next state change. Either way, the WCAG 2.3.3 guidance applies: motion triggered by interaction should be suppressible, and large translation or parallax should be replaced with a simple opacity change under reduced-motion preference. When you script motion, also restore the accessibility tree state (the accordion sets hidden on close) so screen readers are not left announcing a visually collapsed panel.


DevTools debugging workflow

  1. Animations panel (Chrome/Edge DevTools → More tools → Animations): record an interaction. Both CSS and WAAPI animations appear on the same timeline with draggable scrubbers. This is the fastest way to confirm a WAAPI animation registered at all — if nothing shows up, your keyframes array or duration is malformed.
  2. element.getAnimations() in the Console: select the node, run $0.getAnimations(), and inspect each Animation's playState, currentTime, and effect.getTiming(). This reveals leftover animations that were never cancel()-ed and are silently holding fill: forwards styles.
  3. Performance panel: record a trace and look for purple "Layout" / green "Paint" bars during the animation. Their presence means a non-composited property (like the accordion's height) is animating — expected there, a bug if you intended a transform-only effect.
  4. Layers / paint flashing: toggle layer borders to confirm the animated element is promoted to its own compositor layer. The result is identical whether the animation came from CSS or element.animate(), which is the visual proof that "WAAPI is faster" is a myth.

Browser compatibility

FeatureChrome / EdgeSafariFirefox
CSS animations / @keyframes43+9+16+
element.animate() (core WAAPI)84+13.1+75+
Animation.finished promise84+13.1+75+
getAnimations()84+14+75+
Composited transform/opacity via WAAPI84+13.1+75+

Core WAAPI (element.animate(), Animation.finished, getAnimations()) has been broadly interoperable across Chrome/Edge 84+, Safari 13.1+ (14+ for getAnimations()), and Firefox 75+ since roughly 2020, so it is safe to use without polyfills in evergreen targets. Newer specialised pieces such as ScrollTimeline lag this baseline and need feature detection; treat them separately from the stable core API.


Common pitfalls

PitfallCauseResolution
WAAPI styles "stick" after the animationfill: "forwards" leaves the animation holding final values indefinitelyCall anim.commitStyles() then anim.cancel(), or set the resting value in CSS and use fill: "none".
Reaching for WAAPI expecting speedBelief that JS animation is faster than CSSBoth share the compositor; choose WAAPI for control over dynamic values, not throughput.
Class-toggle reversal jumpsCSS animations restart instead of reversing from the live positionUse element.animate() and call reverse(), which retimes from currentTime.
Orphaned animations leak stylesCreated animations never cancel()-ed on re-triggerTrack the active Animation and cancel() it before starting a new one.
Reduced-motion ignored in JS pathOnly the CSS path guarded prefers-reduced-motionRead matchMedia(...).matches in script and collapse duration to 0.

FAQ

Is the Web Animations API faster than CSS animations? No. Both run on the same animation engine and can be composited off the main thread when they animate transform, opacity, or filter. WAAPI is not inherently faster; it just gives you imperative control. Choose it for dynamic values and runtime control, not for speed.

Can I cancel or reverse a CSS animation mid-flight? Not cleanly with CSS alone. CSS animations restart or jump when you toggle classes. The Web Animations API exposes pause(), reverse(), cancel(), and a settable currentTime, so use element.animate() when you need to interrupt motion smoothly.

When should I keep an animation CSS-only? Keep it CSS-only when the values are known at author time, the trigger is a state or pseudo-class such as :hover or :checked, and you do not need to interrupt, reverse, or measure progress. This covers most hover effects, loaders, and state transitions.

Does the Web Animations API replace @keyframes? No. element.animate() accepts the same keyframe and timing concepts and produces Animation objects you can control in JavaScript, but @keyframes remains the declarative authoring format. They share one model, so you can mix them and read CSS animations back via getAnimations().


Related articles

More pages in the same section.

t>