When to Use JavaScript Instead of CSS for Animation

CSS handles the overwhelming majority of interface motion, but a handful of scenarios genuinely require JavaScript or the Web Animations API. This page is the practical companion to CSS Animation vs the Web Animations API: rather than comparing the two surfaces in the abstract, it walks the concrete cases where the declarative approach runs out of expressiveness — and, just as importantly, the CSS-first alternative to try first in each one. The narrow question is: given a specific effect, can CSS do it, and if not, exactly which capability is missing?

The four cases that recur in production are layout transitions where positions are only known at runtime (FLIP), scroll-driven effects beyond what animation-timeline can express, physics-based motion with velocity, and interrupting or reversing an animation from its live position. Everything else — hover feedback, loaders, state changes, entrance effects — should stay declarative for resilience and lower cost.


Approach rationale: prefer CSS, escalate deliberately

The default is CSS because declarative motion keeps working when scripts fail, costs no per-frame JavaScript, and is trivially guarded by prefers-reduced-motion. You only escalate to JavaScript when a value or a control cannot be expressed in a stylesheet. There are exactly two triggers: the animated value is computed at runtime (geometry, velocity, scroll math that animation-timeline cannot map), or you must steer the animation while it runs (interrupt, reverse from the current position, scrub). If neither trigger is present, JavaScript adds fragility for no gain.

Accessibility is part of the rationale, not an afterthought. Scripted motion must read prefers-reduced-motion and collapse or skip the animation, and FLIP transitions in particular should degrade to an instant state change because animating a moving element across the viewport is exactly the kind of large motion the preference is meant to suppress. The flowchart below is the decision in one glance.

Can CSS do it? decision flowchart A flowchart that routes an animation requirement to CSS or to JavaScript based on whether values are runtime-computed or motion must be interrupted. Can CSS do it? Static, known values? yes Use CSS transition / keyframes no Scroll-linked? use animation-timeline Runtime geometry? FLIP Velocity / interrupt? physics, reverse mid-flight Reach for JS / WAAPI

Complete working implementation: a FLIP layout transition

The canonical case JavaScript exists for is FLIP — First, Last, Invert, Play. When an element changes position because the layout reflowed (a card moving as a list reorders, a thumbnail expanding into a hero), CSS cannot animate it: the start and end coordinates are only knowable after the browser computes layout. FLIP measures both, applies an inverting transform so the element looks unmoved, then animates that transform to zero. The result is a smooth, composited move using only transform.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<style>
  .grid { display: flex; flex-wrap: wrap; gap: 12px; }
  .card {
    width: 120px; height: 80px; border-radius: 10px;
    background: #7aa2ff; color: #fff; display: grid; place-items: center;
    /* No transition here: FLIP drives motion via the Web Animations API. */
  }
  .grid.compact .card { width: 80px; height: 80px; }
  button { margin-bottom: 16px; }
</style>
</head>
<body>
  <button id="shuffle">Reorder</button>
  <div class="grid" id="grid">
    <div class="card">1</div><div class="card">2</div>
    <div class="card">3</div><div class="card">4</div>
    <div class="card">5</div>
  </div>

  <script>
    const grid = document.getElementById("grid");
    const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;

    document.getElementById("shuffle").addEventListener("click", () => {
      const cards = [...grid.children];

      // FIRST: record each card's current position.
      const first = new Map(cards.map((el) => [el, el.getBoundingClientRect()]));

      // Mutate the DOM: reverse the order (the "layout change").
      cards.reverse().forEach((el) => grid.appendChild(el));

      cards.forEach((el) => {
        // LAST: read the new position after the reflow.
        const last = el.getBoundingClientRect();
        const f = first.get(el);

        // INVERT: compute the delta and apply it as a transform so the
        // card visually stays where it was. These deltas are the runtime
        // values CSS alone cannot know.
        const dx = f.left - last.left;
        const dy = f.top - last.top;

        // PLAY: animate the inverting transform back to zero. Honour the
        // reduced-motion preference by snapping instantly.
        el.animate(
          [
            { transform: `translate(${dx}px, ${dy}px)` },
            { transform: "translate(0, 0)" },
          ],
          {
            duration: reduce ? 0 : 320,
            easing: "cubic-bezier(0.2, 0.8, 0.2, 1)",
          }
        );
      });
    });
  </script>
</body>
</html>

Key technique callout: invert, then animate to zero

The single idea that makes FLIP work is the Invert step. After the DOM mutates and the browser has already painted the element at its new position, you apply transform: translate(dx, dy) where dx/dy are the old position minus the new position. Because transform is a visual-only, composited property, the element appears to occupy its original spot without affecting layout. You then animate that transform to translate(0, 0), and the eye reads a smooth glide from old to new. The animation is pure transform, so it composites at 60fps; the only main-thread cost is the two getBoundingClientRect() reads, taken in a tight First/Last pair to avoid layout thrashing. CSS cannot participate because dx and dy do not exist until layout runs.


Variation or extension: reduced-motion and the CSS-first alternative

For the other three cases, always test the CSS-first option before scripting. Scroll-linked progress bars and reveal-on-scroll effects are now declarative via animation-timeline: scroll() and view(), which the Keyframe Animation Patterns guide builds on — only escalate to a scroll listener when the math is arbitrary. Physics often reads convincingly as a tuned cubic-bezier() overshoot; reserve a spring library for velocity-driven motion like a flung draggable. Interrupt/reverse is the WAAPI's reverse() and settable currentTime.

The reduced-motion guard already shown collapses duration to 0, but you can go further and skip the animation entirely so no transform is ever applied:

if (matchMedia("(prefers-reduced-motion: reduce)").matches) {
  cards.reverse().forEach((el) => grid.appendChild(el)); // just reorder, no FLIP
} else {
  // ...run the First/Last/Invert/Play sequence...
}

This mirrors the CSS pattern of wrapping motion in @media (prefers-reduced-motion: reduce) — the instant state change remains correct and usable.


Browser support note

The Web Animations API used here (element.animate() returning an Animation) is interoperable in Chrome/Edge 84+, Safari 13.1+, and Firefox 75+, so FLIP needs no polyfill on evergreen targets. The declarative scroll alternative, animation-timeline with scroll()/view(), shipped in Chrome/Edge 115+ and Firefox 114+ (behind broader rollout), with Safari support arriving later; gate it with @supports (animation-timeline: scroll()) and fall back to a static state. getBoundingClientRect() is universal.


FAQ

When is JavaScript genuinely required for an animation? JavaScript is required when the values depend on measured layout (FLIP), when motion must continue with physics like spring or inertia, when you must interrupt and reverse from a live position, or for scroll effects that cannot be expressed with animation-timeline. Static, state-triggered motion should stay in CSS.

What is the FLIP technique? FLIP stands for First, Last, Invert, Play. You measure an element's start and end geometry, apply an inverting transform so it appears unmoved, then animate the transform to zero. It needs JavaScript because the start and end positions are only known at runtime.

Can CSS handle scroll-linked animation without JavaScript? Often yes. The animation-timeline property with scroll() and view() covers progress bars and reveal-on-scroll effects declaratively. Reach for JavaScript only when the effect needs arbitrary scroll math or must coordinate with non-scroll state.

Should I use a physics library for spring animations? Only when a single CSS cubic-bezier() curve cannot fake the feel convincingly. Many springs read fine as a tuned cubic-bezier() or a steps-free overshoot keyframe. Use a spring library when motion must respond to variable velocity, such as a flung, draggable element.


Related articles

More pages in the same section.