Transitions and Animation

Article · 13:00

Two properties are cheap to animate; everything else makes the browser redo work on every frame, and at 60fps you have about 16 milliseconds to spend. Knowing which is which is most of smooth motion - plus the media query you owe your users.

This lesson has 1 runnable example. Press Try yourself! under any of them to open it in the editor.

Transitions and Animation

Motion in an interface has one job: to explain what just happened. A panel that slides out from the button you pressed tells you where it came from. A row that fades as it is removed tells you it is gone rather than hidden. Motion that does not explain anything is decoration, and decoration costs frames.

CSS gives you two tools. A transition animates a change you made. An animation runs a sequence you declared. Knowing which one a problem wants is most of the skill.

A transition animates a change

A transition does nothing on its own. It waits for a property to change, and then interpolates instead of jumping.

.button {
  background: #0f766e;
  transform: translateY(0);
  transition: background 150ms ease, transform 150ms ease;
}
.button:hover { background: #115e59; }
.button:active { transform: translateY(1px); }

Four things make up a transition, and the shorthand takes them in this order: property, duration, timing function, delay. transition: transform 200ms ease-out 50ms.

Two rules about where to write it. Put the transition on the base state, not on :hover - otherwise the move in animates and the move out snaps back, because the rule carrying the transition no longer matches once the cursor leaves. And name the properties rather than writing transition: all. all means the browser watches every animatable property, which is both slower and a source of surprises when a later rule changes something you did not expect to move.

An animation runs a sequence

When the motion is not a change between two states - a spinner, a pulse, a three-step entrance - you declare the sequence as keyframes.

@keyframes pulse {
  from { opacity: 1; }
  50%  { opacity: 0.4; }
  to   { opacity: 1; }
}

.saving {
  animation: pulse 1.2s ease-in-out infinite;
}

animation takes name, duration, timing function, delay, iteration count, direction, fill mode and play state. In practice you write the first three or four and reach for the rest when you need them:

  • animation-fill-mode: forwards keeps the final keyframe's values after the animation ends. Without it the element snaps back to its unanimated state, which is almost never what an entrance animation wants.
  • animation-direction: alternate runs every other iteration backwards, so a two-keyframe animation becomes a smooth there-and-back without you writing the return trip.
  • animation-delay accepts negative values, which start the animation partway through. That is how you stagger a row of dots without three separate keyframe blocks.

A complete worked example

A notification that slides in, can be dismissed, and respects the user's motion preference.

HTML Example 1 95 lines
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Motion that explains</title>
    <style>
      *, *::before, *::after { box-sizing: border-box; }
      body {
        font: 16px/1.6 system-ui, sans-serif;
        margin: 0;
        padding: 2rem;
        background: #fbfbfd;
        color: #16181d;
      }

      @keyframes slide-in {
        from { opacity: 0; transform: translateX(1.5rem); }
        to   { opacity: 1; transform: translateX(0); }
      }

      .toast {
        display: flex;
        align-items: center;
        gap: 1rem;
        max-width: 26rem;
        padding: 1rem 1.25rem;
        margin-block-end: 0.75rem;
        background: #fff;
        border: 1px solid #e3e5ea;
        border-left: 3px solid #0f766e;
        border-radius: 8px;
        /* The entrance. forwards holds the end state. */
        animation: slide-in 220ms cubic-bezier(0.2, 0, 0, 1) forwards;
      }

      .toast p { margin: 0; flex: 1; }

      /* The exit is a state change, so it is a transition. The class
         is added by script, the element is removed on transitionend. */
      .toast.leaving {
        opacity: 0;
        transform: translateX(1.5rem);
        transition: opacity 180ms ease-in, transform 180ms ease-in;
      }

      .dismiss {
        border: 0;
        background: transparent;
        font: inherit;
        color: #5c626e;
        cursor: pointer;
        padding: 0.25rem 0.5rem;
        border-radius: 6px;
        transition: background 120ms ease, color 120ms ease;
      }
      .dismiss:hover { background: #f1f2f5; color: #16181d; }
      .dismiss:focus-visible { outline: 2px solid #0f766e; outline-offset: 2px; }

      /* Not optional. Some people get motion sickness from this. */
      @media (prefers-reduced-motion: reduce) {
        .toast, .toast.leaving, .dismiss {
          animation: none;
          transition: none;
        }
      }
    </style>
  </head>
  <body>
    <h1>Toasts</h1>
    <button id="add">Add a toast</button>

    <div id="stack" role="status" aria-live="polite"></div>

    <script>
      const stack = document.getElementById('stack');
      let n = 0;

      document.getElementById('add').addEventListener('click', () => {
        const toast = document.createElement('div');
        toast.className = 'toast';
        toast.innerHTML = '<p>Saved change number ' + (++n) + '.</p>' +
                          '<button class="dismiss">Dismiss</button>';
        stack.append(toast);
      });

      stack.addEventListener('click', (event) => {
        if (!event.target.closest('.dismiss')) return;
        const toast = event.target.closest('.toast');
        toast.classList.add('leaving');
        toast.addEventListener('transitionend', () => toast.remove(), { once: true });
      });
    </script>
  </body>
</html>

Open this example in the editor and change it. Nothing you type is saved.

Notice the division of labour. The entrance is an animation, because there is no prior state to transition from - the element did not exist a moment ago. The exit is a transition, because it is a change from one state to another, and because transitionend gives us a reliable moment to remove the element from the document. Using an animation for the exit would mean listening for animationend and remembering forwards, which works but reads worse.

The two properties that are cheap, and why

A browser renders in stages: it computes styles, works out geometry (layout), paints pixels, and composites the painted layers onto the screen. Animating a property determines how far back in that pipeline it has to start again, every frame.

  • opacity and transform are composited. The layer is already painted; the compositor just draws it somewhere else or with different alpha. This can happen off the main thread, which is why these keep animating smoothly even while JavaScript is busy.
  • width, height, top, left, margin and padding force layout. Changing one means recalculating the position of everything that depends on it, then repainting, then compositing - 60 times a second, on the main thread.

So transform: translateX(20px) and left: 20px look identical and cost nothing alike. Prefer the transform. For a position, animate transform: translate() rather than offsets; for a size, transform: scale() rather than width and height - accepting that scale stretches the content, which is fine for a backdrop and wrong for text.

The honest exception: height from 0 to auto has no transform equivalent. Modern CSS gives you interpolate-size: allow-keywords and calc-size() for this, and grid-template-rows from 0fr to 1fr works today in a grid wrapper. Both animate layout, so use them on one element at a time rather than on a list of thirty.

Timing functions say what kind of thing moved

The default ease is fine and slightly characterless. The choice that matters is direction:

  • ease-out starts fast and settles. Right for anything entering - it arrives promptly and comes to rest.
  • ease-in starts slow and accelerates away. Right for anything leaving.
  • linear has no acceleration. Right for a spinner or a progress bar, wrong for anything representing a physical object.
  • cubic-bezier(0.2, 0, 0, 1) is a sharper ease-out: it moves decisively and lands softly. This is what most design systems mean by their standard easing.

Duration matters more than curve. Under 100ms reads as instant and the motion is wasted. Over about 400ms the interface feels slow and people start clicking again. Most interface motion belongs between 150ms and 250ms; a large element crossing a large distance can justify 300ms.

Reduced motion is a requirement

prefers-reduced-motion is set by people for whom animation causes nausea or vertigo - a vestibular disorder is not a preference about taste. Honour it on every page, every time.

The right response is usually not "no feedback at all" but "no movement": keep an opacity fade, drop the slide and the scale. A transition: none block, as in the example above, is the blunt version and is always acceptable. What is not acceptable is leaving it out.

When it goes wrong

Symptom Cause
It animates in but snaps out The transition is on :hover, not on the base state
Nothing animates at all The property is not animatable, or both ends are auto
An entrance animation reverts when it ends animation-fill-mode: forwards is missing
Motion stutters while the page is busy Animating layout properties instead of transform and opacity
transitionend fires several times Several properties are transitioning; check event.propertyName
transitionend never fires The value did not actually change, or the element was removed first
A display: none element will not fade in It has no starting value to transition from; use @starting-style or a frame's delay
It is smooth on your laptop and awful on a phone Too many animated elements, or a layout property on a long list

A check you can run

Open DevTools, find the rendering panel, and turn on "Paint flashing" and the frame-rate meter. Then trigger your animation.

A transform or opacity animation should flash green once at the start and then stay quiet, holding a steady frame rate. A width or top animation repaints a region every single frame - you will see it flashing continuously, and on a mid-range phone you will see the frame rate fall with it. That difference, visible in a few seconds, is the whole argument for the two cheap properties.

Then set your operating system to reduce motion and reload. If anything still slides, you have a bug that affects real people.