skip to content

A UI animation can be driven three ways: a CSS transition or keyframe animation, the Web Animations API through Element.animate(), or a requestAnimationFrame loop that writes styles every frame. How do you choose between them?

level: middleimportance: should knowfreq 46%

answer

  1. who computes the value each frame
  2. declarative first, imperative when you need control
  3. runtime values or playback control
  4. finished promise, playbackRate, getAnimations
  5. physics and canvas force a per-frame loop

basics

~20 s

Default to declarative CSS for state-driven motion with known start and end values. Reach for Element.animate() when values are computed at runtime or you need playback control such as pause, reverse or a finished promise. Use a per-frame JavaScript loop only for motion that cannot be expressed as interpolation between keyframes.

solid answer

~50 s

Order them by how much per-frame JavaScript they require, and take the cheapest that does the job. CSS transitions and `@keyframes` cover most product motion: the values are known up front, the browser owns the timing, and there is no JavaScript in the frame at all — which also means the animation can keep running when the main thread is busy. The Web Animations API, via `Element.animate()`, runs on the same engine but gives you an imperative handle: keyframe values computed at runtime, `pause()`, `reverse()`, `playbackRate`, `currentTime` seeking, a `finished` promise for sequencing, and `getAnimations()` to find and cancel what is in flight. A `requestAnimationFrame` loop is the last resort, for motion that genuinely is not interpolation between two states — spring physics, values driven by live input or sensor data, canvas and WebGL drawing. It costs main-thread work on every single frame, and it is the only one of the three that freezes when something else blocks the thread.

code

javascript · 19 lines
javascript
// Runtime-computed distance plus playback control: the Web Animations API case.
async function slideIn(panel, distance) {
  // Cancel anything already animating this element so the two do not fight.
  panel.getAnimations().forEach((running) => running.cancel());

  const animation = panel.animate(
    [
      { transform: `translateX(${distance}px)`, opacity: 0 },
      { transform: 'translateX(0)', opacity: 1 },
    ],
    { duration: 320, easing: 'cubic-bezier(0.2, 0, 0, 1)', fill: 'both' }
  );

  await animation.finished;

  // Write the end state into the element and drop the animation's hold on it.
  animation.commitStyles();
  animation.cancel();
}

go deeper

for a junior

Know the three options exist and that a CSS transition is the normal choice for a two-state UI change. Be able to say that Element.animate() is the JavaScript equivalent and that a per-frame loop is a bigger commitment.

for a middle

Explain the selection criteria: static versus runtime-computed values, and what the Web Animations API adds — pause, reverse, playbackRate, currentTime, the finished promise, getAnimations() for interruption.

for a senior

Show that you weigh robustness too: a per-frame loop competes with everything else on the main thread, and interruption and cleanup are far cheaper with an animation handle. Be ready to justify picking a loop for physics or canvas.

for a principal

Set the house rule — which driver is the default in the design system, when a per-frame loop needs justification, and how interruption and cleanup are handled consistently so animations do not accumulate or fight each other across a large app.

## Three drivers, one ordering principle All three produce motion. They differ in *who computes the value on each frame*, and that is the whole decision: - **CSS transition / `@keyframes`** — you declare the start and end states; the browser interpolates. - **Web Animations API (`Element.animate()`)** — you hand the browser the same kind of keyframe list from JavaScript and get an `Animation` object back; the browser still interpolates. - **`requestAnimationFrame` loop** — your code computes a value every frame and writes it to the DOM itself. The cheapest option is the one that puts the least JavaScript in the per-frame path. Start at the top of that list and move down only when something forces you to. ## CSS transitions and keyframes: the default Most product motion is state-driven: a menu opens, a button hovers, a toast enters, a chevron rotates. The start and end values are known when you write the CSS, so a transition expresses it exactly: ```css .toast { opacity: 0; transform: translateY(8px); transition: opacity 180ms ease-out, transform 180ms ease-out; } .toast[data-visible] { opacity: 1; transform: none; } ``` What you get: no per-frame JavaScript, timing owned by the browser, easing and delays declared in one place, and — importantly for robustness — an animation that can continue even while the main thread is occupied with something else. What you give up: the values must be static, sequencing beyond `transition-delay` is awkward, and control after it starts is limited to changing classes. Use `@keyframes` instead of a transition when the motion has intermediate steps, needs to loop, or must run without a state change (a pulsing indicator). ## The Web Animations API: same engine, a handle `Element.animate()` takes the same conceptual input — a keyframe list plus timing options — and returns an `Animation`: ```js const animation = el.animate( [{ transform: 'translateX(0)' }, { transform: `translateX(${distance}px)` }], { duration: 320, easing: 'cubic-bezier(0.2, 0, 0, 1)', fill: 'both' } ); await animation.finished; ``` Reach for it when at least one of these is true: 1. **The values are only known at runtime.** A distance measured from the DOM, a colour from a theme object, a duration proportional to how far something travels. Writing those into CSS means injecting style strings or custom properties from JavaScript anyway. 2. **You need playback control.** `pause()`, `play()`, `reverse()`, `cancel()`, `finish()`, `playbackRate` for slowing or speeding a running animation, and `currentTime` for seeking to a position — for scrubbing, for interruption, for debugging. 3. **You need to sequence or clean up.** `animation.finished` is a promise, which composes with `async/await` far better than a `transitionend` listener that you must remember to remove and that fires per property. `document.getAnimations()` and `element.getAnimations()` let you find everything in flight and cancel it — invaluable when a user interrupts a transition halfway. 4. **The element is created and animated in the same breath**, where adding a class and waiting a frame for the transition to register is the fiddly alternative. Because WAAPI feeds the same animation engine as CSS, choosing it does not by itself make the animation more expensive than the CSS equivalent. ## The requestAnimationFrame loop: last resort, and sometimes the only option A per-frame loop is right when the value on each frame is not an interpolation between two known states: - **Physics** — springs, inertia, momentum scrolling, anything whose next value depends on velocity rather than elapsed fraction. - **Live input** — a value tracking pointer position, device orientation, audio amplitude, or streaming data. - **Drawing** — `<canvas>` and WebGL, where there is no element whose style you could transition in the first place. The cost is unavoidable: your code runs on the main thread every frame, inside the frame budget, and the animation stops dead if anything else occupies that thread. That is why a rAF-driven animation and a declarative one can behave completely differently under load even when they look identical when the page is idle. ## Two modern refinements Scroll-linked effects used to be the classic reason to write a rAF loop. CSS scroll-driven animations (`animation-timeline: scroll()` and `view()`) express many of them declaratively instead; support is still uneven across browsers, so check before relying on it rather than assuming. Separately, for cross-element transitions where whole sections of the page change at once, the View Transition API can replace hand-written choreography — again, verify support for your target browsers. ## Answering it well Do not present this as a ranking of "CSS is fast, JavaScript is slow". Present it as: how much of this animation must my code compute, and what control do I need over it once it starts? Declarative when the answer is "nothing and none"; WAAPI when you need the handle; a per-frame loop when the value genuinely cannot be interpolated.

  • You animate an element with Element.animate() and it snaps back to its starting style when the animation ends. Why?
    By default an animation stops affecting the element once it finishes, so the underlying style reasserts itself. Either set `fill: 'forwards'` to keep the end state applied, or — usually better — write the final value into the element's own styles or class and call `commitStyles()`, so the DOM reflects the real end state rather than depending on a lingering animation object.
  • How would you cancel a half-finished transition when the user interrupts it?
    With the Web Animations API it is direct: `element.getAnimations()` returns what is running, and you can `cancel()` or `finish()` each one, or read `currentTime` and start a new animation from where the old one stopped. Pure CSS gives you much less — you change classes and hope the interpolation from the current computed value looks acceptable.
  • Does choosing the Web Animations API over a CSS transition make an animation more expensive?
    No — `Element.animate()` feeds the same animation engine, so an identical keyframe list behaves the same way. The cost difference in this comparison is between declarative animation and a `requestAnimationFrame` loop that computes and writes a value on every frame; WAAPI sits on the declarative side of that line.
  • When is a requestAnimationFrame loop unavoidable?
    When the per-frame value is not interpolation between two known states: spring or inertia physics whose next value depends on velocity, motion tracking live pointer or sensor input, and anything drawn into canvas or WebGL where there is no element style to animate. Everything else usually has a declarative form worth trying first.

saying these in an interview costs you the question

  • Says JavaScript animation is always slower without saying what runs per frame
  • Writes a rAF loop for a simple two-state transition
  • Thinks Element.animate() is a slower polyfill for CSS animation
  • Uses transitionend for sequencing when a finished promise exists
  • Cannot name a case where a per-frame loop is genuinely required

context