Chapter 3

Native CSS Animations & the WAAPI

Declarative @keyframes vs imperative element.animate().

@keyframestransitionWeb Animations API
Native animations can run on the compositor. WAAPI is the imperative engine the libraries build on: keyframes, playback control, composite operations.

There are two ways to animate natively in the browser, and they share one engine. The declarative path — @keyframes and transition — hands the whole timeline to the browser. The imperative path — element.animate(), the Web Animations API — hands you a live Animation object you can play, pause, seek and reverse. Every JS animation library you'll meet later is, underneath, orchestrating one of these two.

Declarative vs imperative

With @keyframes you describe the destination states and the browser interpolates forever. With a transition you describe nothing but the easing and let a state change (a class toggle, a hover) trigger an implicit tween from the old value to the new one. Both are set-and-forget — great until you need to interrupt them.

The Web Animations API (WAAPI) is the imperative counterpart. element.animate(keyframes, options) kicks off the same kind of animation but returns an Animation object — a handle onto a running timeline.

Same animation, three ways

Below is one visual — a box translating, rotating a full turn, and fading — authored three ways. Flip between the techniques to read each one's code. The live box on the right is the real WAAPI version: the scrubber is wired straight to the Animation's currentTime, and the buttons call .play() / .pause() / .reverse() on it.

One visual · three techniques · live WAAPI transport live demo
forward
currentTime = 0ms / 2400ms (0%) paused
playbackRate
// Imperative: animate() returns a first-class Animation object.
const anim = box.animate(
  [
    { transform: 'translateX(-110px) rotate(0deg)   scale(1)',    opacity: .25, offset: 0   },
    { transform: 'translateX(0)      rotate(180deg)  scale(1.35)', opacity: 1,   offset: .5  },
    { transform: 'translateX(110px)  rotate(360deg)  scale(1)',    opacity: .25, offset: 1   }
  ],
  { duration: 2400, easing: 'ease-in-out', fill: 'both' }
);

// You hold the timeline. Grab and redirect it any time:
anim.pause();
anim.currentTime = 1200;       // seek (drives the scrubber →)
anim.playbackRate = 2;         // half-speed … 2×
anim.reverse();                // flip direction, keep momentum
await anim.finished;           // a Promise
box.getAnimations();           // [anim] — enumerate live animations

All three produce the same motion. Only WAAPI hands you the live object the scrubber and buttons are driving.

Composite operations: replace vs add

Two animations can target the same property at once. By default the later one 'replace's the earlier — the last writer wins. With composite: 'add' their values are summed, letting you layer independent motions (a constant drift plus a reactive nudge) without one clobbering the other. 'accumulate' is a third mode that adds repeating effects across iterations.

Same property, two composite modes live demo
composite: 'replace'

The bob overwrites the sway — only the last animation's translate wins, so the box just moves up & down.

composite: 'add'

The bob is summed with the sway — both effects combine into a looping figure-eight-ish path.

Web Animations API (element.animate + composite) Baseline 2022
Chrome/Edge
Firefox
Safari

Core element.animate() and playback control are Baseline across all engines. composite:'add'/'accumulate' shipped a bit later but is now broadly supported; getAnimations() is everywhere modern.

Playground: a WAAPI sandbox

element.animate() is built into the browser — no imports. Edit the keyframes array and the options object below and watch them apply to a live box. Try changing iterations to Infinity, swapping the easing for a cubic-bezier(...), or adding an opacity channel to a keyframe.