Animate only transform and opacity, keep motion short and purposeful, and respect reduced motion.
Why it matters
Good motion explains what happened: a menu grows from the button that opened it, a toast slides in from where it will live, a checkmark confirms a save. Bad motion is slow, janky, or makes people feel sick. Some users with vestibular disorders get dizziness or nausea from large movement on screen, and WCAG asks that motion triggered by interaction can be turned off (Success Criterion 2.3.3, Animation from Interactions, level AAA) and that anything moving for more than five seconds can be paused (2.2.2, level A).
Motion is also a performance problem. An animation that drops frames makes an app feel broken even when it works.
Why transform and opacity
To draw a frame, the browser may do three kinds of work:
- Layout: work out the size and position of every affected box.
- Paint: fill in the pixels of those boxes.
- Composite: stack the painted layers together, often on the GPU.
Animating width, height, top, left, margin or padding changes geometry, so the browser may redo layout and paint on every frame, for the element and everything around it. Animating transform (translate, scale, rotate) and opacity can usually be handled in the composite step alone, which is cheap and smooth even on modest phones.
/* Janky: layout every frame, and 'all' animates anything that changes */
.panel { transition: all 0.8s; left: -320px; }
.panel.is-open { left: 0; }
/* Smooth: compositor-friendly properties, named explicitly */
.panel {
opacity: 0;
transform: translateY(-8px) scale(0.98);
transition-property: opacity, transform;
transition-duration: 200ms;
transition-timing-function: cubic-bezier(0.2, 0, 0, 1);
}
.panel.is-open { opacity: 1; transform: none; }
Never write transition: all. It animates properties you did not intend (including layout ones), and a later change to the element can suddenly become a slow animation.
Note that modern CSS also has standalone translate, scale and rotate properties. They are composited too, but you must transition the property you actually change: transitioning transform does nothing if your code sets scale.
To animate height, such as an accordion, prefer a technique that avoids per-frame layout of the whole page: animate a grid-template-rows from 0fr to 1fr on a small container, or scale and fade the content, and keep the duration short.
Timing that feels right
- Short. Most UI transitions are 100–300 ms. Small things (a hover colour, a checkbox) are near the low end; larger moves (a sheet sliding in) near the high end. Anything over about 500 ms feels slow for a routine action.
- Easing. Entering elements decelerate (ease-out); exiting elements accelerate (ease-in) and can be a little faster than they entered.
- Tokens. Put durations and easings in CSS custom properties (
--motion-fast: 150ms) so the whole app moves consistently and you can tune it in one place. - Purpose. If you cannot say what an animation communicates (where something came from, what changed, that an action succeeded), remove it.
Respecting reduced motion
Operating systems have a "reduce motion" setting, exposed to CSS as a media query:
@media (prefers-reduced-motion: reduce) {
.panel {
transform: none; /* no movement */
transition-property: opacity; /* a quick fade is still fine */
transition-duration: 150ms;
}
}
Reduced motion does not have to mean no feedback. Replace movement (slides, zooms, parallax, spinning) with a short fade or an instant change. Keep essential indicators such as a loading spinner, and remove decorative effects like confetti entirely.
JavaScript animations (scrolling, canvas, the Web Animations API) need the same check:
function prefersReducedMotion() {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
target.scrollIntoView({ behavior: prefersReducedMotion() ? "auto" : "smooth" });
To test it, turn on Reduce Motion in your operating system settings, or use DevTools: Rendering panel > Emulate CSS media feature prefers-reduced-motion.
Accessibility of animated components
Motion must not break the component's semantics. A menu that fades out is still in the DOM and still focusable while invisible unless you stop that. Mark the closed panel inert (or hidden after the exit finishes) so keyboard and screen reader users cannot land in an invisible menu, and keep aria-expanded on the toggle in sync with the real state.
Common mistakes
transition: all, or animatingwidth,leftorheighton large elements.- Durations of a second or more for everyday actions.
- Ignoring
prefers-reduced-motion, or handling it by disabling every transition including focus and colour feedback. - Invisible but focusable panels after a fade-out.
- Motion that only plays on page load and delays content the user came for.
Quick recap
- Animate
transformandopacity(ortranslate/scale/rotate), named explicitly; nevertransition: all. - Keep transitions short (about 100–300 ms) with consistent, tokenised easings.
- Under
prefers-reduced-motion: reduce, replace movement with a fade or an instant change, in CSS and JavaScript. - Keep semantics in sync:
aria-expanded, andinertorhiddenon closed content.
Try it
In the Do tab, rework a janky menu animation so it uses only compositor-friendly properties, respects reduced motion, and keeps its ARIA state correct.