4 min read

CSS animations: how speed is everything

CSS animations: how speed is everything

Nobody has ever praised your dropdown animation. The best outcome an interface animation can achieve is that the user doesn't consciously notice it, and the fastest way to guarantee they do notice is to make it too slow. A 500ms fade on a menu sounds harmless in a design review. In use, it's a 500ms tax charged every single time someone opens that menu, and by the fortieth open they're not admiring your easing curve, they're waiting for your interface.

Speed decides everything about whether an animation helps or hurts, and it does so twice: once in the duration you choose, and again in whether the browser can actually render the thing at 60 frames per second. Get either wrong and you'd have been better off with no animation at all.

The 200 millisecond window

Human perception gives you a narrow band to work in. Below roughly 100ms, a transition reads as instantaneous; the animation might as well not exist. Above 300ms, a UI transition starts to register as delay. The useful range for most interface work sits between those, and 200ms is a defensible default for almost everything: hover states, dropdowns, toggles, small panels.

.menu {
  opacity: 0;
  transform: translateY(-8px);
  transition: opacity 200ms ease-out, transform 200ms ease-out;
}
.menu.open {
  opacity: 1;
  transform: translateY(0);
}

Two refinements on the default. First, scale duration with distance and size: a tooltip appearing next to the cursor can run 120ms, while a full-screen drawer sliding in from the edge earns 250 to 300ms, because a large element crossing a large distance at tooltip speed looks like it's teleporting. Second, make exits faster than entrances. When a user opens something, the animation is providing orientation. When they dismiss it, they've already decided it should be gone, and every extra millisecond it lingers is friction. A reasonable rule: exit at two thirds of the entrance duration.

Anything at 400ms or above needs to justify itself, and in interface work it almost never can. Long durations belong to storytelling contexts, hero sections and onboarding sequences, where the animation is content. On a control someone uses repeatedly, 400ms is a bug filed by nobody and felt by everybody.

Easing is how speed changes, and it matters as much as duration

Two animations with identical 200ms durations can feel completely different, because easing distributes that speed over time. The browser default, ease, is mediocre for UI: it accelerates into the movement, which reads as sluggish on entrances.

The working rules are simple. Elements entering the screen should use ease-out: they start fast and decelerate into place, which reads as responsive because the movement begins the instant the user acts. Elements leaving should use ease-in, accelerating away like something being discarded. Reserve linear for continuous mechanical motion such as spinners, where any easing makes the loop stutter at the seam.

When the built-in keywords feel flat, write your own curve:

:root {
  --ease-out-quart: cubic-bezier(0.25, 1, 0.5, 1);
}
.panel {
  transition: transform 250ms var(--ease-out-quart);
}

That curve front-loads the movement aggressively, so the panel covers most of its distance in the first 100ms and spends the rest settling. The element feels quick without the duration actually being shorter, which is the cheapest performance win in this entire article: perceived speed you get for free by moving the fast part of the animation to the front.

The other speed: what the browser can render

Duration is the speed you choose. Frame rate is the speed you earn, and you earn it by animating the right properties. Every visual change goes through some subset of the browser's pipeline: style, layout, paint, composite. Animate a property that triggers layout, and the browser recalculates geometry for affected elements on every frame, 60 times a second, on the main thread, competing with your JavaScript.

/* Janky: width and margin trigger layout every frame */
.card:hover {
  width: 320px;
  margin-top: -4px;
}

/* Smooth: transform composites on the GPU */
.card {
  transition: transform 200ms ease-out;
}
.card:hover {
  transform: scale(1.05) translateY(-4px);
}

The safe list is short: transform and opacity. Both can be handled by the compositor thread, which means they keep running at 60fps even while the main thread is busy parsing your bundle. Nearly every common UI animation can be rebuilt from those two properties. Sliding is translate, growing is scale, revealing is opacity, and a collapsing accordion, the classic layout-thrashing offender, can usually be faked with transform: scaleY plus a fade, or handled with the newer interpolate-size where support allows.

Verify instead of assuming. Chrome DevTools has a frame rendering stats overlay under the Rendering tab that shows live fps, and the Performance panel will show you exactly which frames blew their 16.7ms budget and why. Purple bars are layout; if your animation produces them on every frame, you've picked the wrong property. And treat will-change as a last resort rather than seasoning: it promotes elements to their own compositor layers, each one costing memory, and sprinkled everywhere it makes performance worse. Apply it just before a heavy animation runs and remove it after.

Speed zero is a valid setting

Some users have vestibular disorders that make large movements physically unpleasant, and plenty more have simply turned motion off in their OS settings. Respect that preference, because ignoring it makes your carefully tuned animation an accessibility failure:

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    transition-duration: 0.01ms !important;
  }
}

Near-zero rather than none, so any code listening for transitionend events still fires.

There's a lesson hiding in that media query. An interface with all animation removed still works completely, which tells you what animation actually is: annotation, commentary on state changes, never the state change itself. Commentary should be brief. The next time a 300ms transition is sitting in your stylesheet, cut it to 200ms and try to notice the difference in use. You won't, and your users will only ever feel it as the interface getting out of their way slightly sooner, which is the entire job.


Sources and further reading: the reduced-motion pattern follows Andy Bell's Modern CSS Reset and Eric Bailey's work on prefers-reduced-motion. Duration thresholds draw on Nielsen Norman Group's response-time research and Material Design's motion guidelines. The rendering pipeline explanation owes a debt to Paul Lewis's rendering performance guides on web.dev. Easing values from easings.net.