Motion in an interface is a claim on attention. Every transition, every animated state change tells the user: look here, something changed. The question every animation decision should answer is whether that claim is warranted — and whether the mechanism you chose to make it is the right one.

CSS gives you two distinct tools for motion: transitions and animations. They overlap in output but differ sharply in intent and control.

Transitions: State-to-State Motion

A CSS transition interpolates a property’s value between two states — typically triggered by a pseudo-class like :hover or a class toggle via JavaScript. The syntax is built around four sub-properties:

.button {
  background-color: #0067c6;
  transition-property: background-color;
  transition-duration: 200ms;
  transition-timing-function: ease-out;
  transition-delay: 0ms;
}

.button:hover {
  background-color: #004fa3;
}

Or collapsed into the shorthand:

.button {
  transition: background-color 200ms ease-out;
}

You can transition multiple properties by separating them with commas, or use all — though all is a trap. It catches every animatable property including ones you did not intend to animate, and it makes future refactoring harder when you add properties you specifically do not want to transition.

Transitions are reactive. They fire when a change occurs and reverse when the triggering condition ends. There is no looping, no sequencing, no mid-animation control. When you need those things, you need @keyframes.

Animations: Declared Motion Sequences

The @keyframes rule defines a named sequence of property states at percentage-based waypoints. The animation shorthand attaches that sequence to an element:

@keyframes slide-in {
  from {
    opacity: 0;
    transform: translateY(12px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

.card {
  animation: slide-in 300ms ease-out both;
}

The animation shorthand expands to eight sub-properties, the most consequential being:

  • animation-duration — length of one cycle
  • animation-timing-function — easing across the cycle
  • animation-delay — wait before starting
  • animation-iteration-count — how many times to repeat (infinite for looping)
  • animation-fill-mode — whether the element retains keyframe styles before and after (none, forwards, backwards, both)
  • animation-play-state — running or paused, controllable via JavaScript

The animation-fill-mode: both value in the example above is worth understanding: backwards applies the from keyframe styles during the delay period (preventing a flash of the un-animated state), and forwards retains the to keyframe styles after the animation completes. both does both. This is almost always what you want for entrance animations.

What the Compositor Can and Cannot Do

The single most important performance concept in CSS animation is the compositing layer. Browsers render pages in layers, and certain CSS properties are handled entirely by the GPU compositor — meaning they can animate without triggering layout recalculation or paint.

The two properties that consistently animate on the compositor are:

  • opacity — changes transparency without touching layout
  • transform — handles translation, rotation, scale, and skew entirely in the compositing step

Everything else triggers some combination of layout, paint, or both. Animating width, height, top, left, margin, padding, or border forces the browser to recalculate how elements relate to each other in the document — potentially causing full-page reflows on every animation frame. On a 60fps animation that is 60 reflows per second. On complex pages, this produces visible jank.

The practical rule: if you need to move something, use transform: translate() instead of changing top/left. If you need to show or hide something, animate opacity (and combine it with visibility if you need to remove it from tab order). If you need to resize something, consider whether transform: scale() serves the same visual purpose.

/* Triggers layout — avoid */
.panel {
  transition: width 300ms ease;
}
.panel.open {
  width: 320px;
}

/* Compositor-only — prefer */
.panel {
  transform: scaleX(0);
  transform-origin: left;
  transition: transform 300ms ease;
}
.panel.open {
  transform: scaleX(1);
}

The CSS Triggers resource maintains a reference of which properties trigger layout, paint, or composite per browser rendering engine — useful when debugging animation performance.

Timing Functions

The timing function controls how a property progresses through its interpolated range over time. The keyword values map to predefined cubic Bezier curves:

  • linear — constant rate, mechanical, rarely correct for physical motion
  • ease — default; starts fast, decelerates to end
  • ease-in — starts slow, accelerates; use for elements leaving the screen
  • ease-out — starts fast, decelerates; use for elements entering the screen
  • ease-in-out — symmetric S-curve, good for looping animations

Physical objects do not move at constant speed. They accelerate from rest and decelerate before stopping. ease-out for entrances and ease-in for exits matches this expectation and reads as natural rather than mechanical.

For fine-grained control, cubic-bezier(x1, y1, x2, y2) accepts four values defining the two control points of the Bezier curve. Tools like the Chrome DevTools cubic-bezier editor or cubic-bezier.com let you manipulate curves visually and copy the resulting values.

The steps() timing function produces discrete jumps rather than smooth interpolation — useful for sprite sheet animations or typewriter effects:

@keyframes type-out {
  from { width: 0; }
  to { width: 24ch; }
}

.typewriter {
  overflow: hidden;
  white-space: nowrap;
  animation: type-out 2s steps(24, end) forwards;
}

The first argument to steps() is the number of discrete steps; the second (start or end) controls whether the jump happens at the beginning or end of each interval.

will-change: A Hint, Not a Fix

The will-change property signals to the browser that an element is about to be animated, prompting it to promote the element to its own compositing layer ahead of time:

.animated-panel {
  will-change: transform, opacity;
}

This eliminates the small jank that can occur when a layer is promoted mid-animation. For complex animations on specific elements, it can help.

It comes with real costs, though. Promoted layers consume GPU memory. Overusing will-change — applying it globally, to many elements, or permanently — can degrade performance on memory-constrained devices (which is most mobile hardware). The MDN documentation on will-change explicitly notes that browsers already make optimization decisions on their own, and will-change should be used sparingly when there is a demonstrated performance problem, not as a preemptive blanket.

If you use it, apply it dynamically — add it just before an animation triggers and remove it afterward:

element.addEventListener('mouseenter', () => {
  element.style.willChange = 'transform';
});

element.addEventListener('animationend', () => {
  element.style.willChange = 'auto';
});

prefers-reduced-motion: Beyond “Turn It Off”

The prefers-reduced-motion media query reflects a system-level preference — available on macOS, Windows, iOS, and Android — that users set when motion causes them discomfort. This includes users with vestibular disorders, where screen motion can trigger vertigo and nausea. It also reflects a preference some users have for less visual noise, regardless of medical necessity.

The naive implementation treats this as a binary switch:

@media (prefers-reduced-motion: reduce) {
  * {
    animation: none !important;
    transition: none !important;
  }
}

This works but discards useful motion entirely. A more considered approach distinguishes between motion that is decorative and motion that communicates — then reduces the former while preserving or substituting the latter.

Consider a loading spinner. The spinning motion may trigger vestibular discomfort, but removing it entirely leaves no feedback that the system is working. A stationary pulsing opacity change communicates the same “working” state without rotation:

@keyframes spin {
  to { transform: rotate(360deg); }
}

@keyframes pulse {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.4; }
}

.spinner {
  animation: spin 800ms linear infinite;
}

@media (prefers-reduced-motion: reduce) {
  .spinner {
    animation: pulse 1.5s ease-in-out infinite;
  }
}

For entrance animations, a common pattern is to collapse the spatial movement while preserving the opacity fade — so content still appears smoothly rather than snapping in, but without the disorienting translation:

@keyframes enter {
  from {
    opacity: 0;
    transform: translateY(16px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

@media (prefers-reduced-motion: reduce) {
  @keyframes enter {
    from { opacity: 0; }
    to { opacity: 1; }
  }
}

WCAG 2.1 Success Criterion 2.3.3 (Level AAA) covers animation from interactions, specifying that users should be able to disable motion animation unless it is essential to functionality. Meeting the reduced-motion media query is the practical mechanism for this.

Practical Patterns

Hover effects. The most common use of transitions. Keep durations short — 150–200ms for color and opacity changes, 200–300ms for spatial changes. Longer durations on hover effects create lag between the user’s action and visual feedback. The transition should be on the base state, not the :hover state, so it applies in both directions:

.nav-link {
  color: #333;
  transition: color 150ms ease;
}
.nav-link:hover {
  color: #0067c6;
}

Entrance animations. Useful for content that appears after the initial page load — modals, toasts, dropdown menus, async-loaded content. Keep them short (200–350ms) and use ease-out so they feel fast to arrive. Avoid animating elements that are present on initial page load; animating everything that exists when a page renders creates sensory noise and can delay perceived time-to-useful.

Page transitions. Single-page application frameworks often animate between routes. The View Transitions API — now supported in Chrome and Safari, with Firefox support in progress — offers a browser-native mechanism for this that coordinates the transition between old and new document states without the complexity of manual animation orchestration. It handles the timing of fade-out and fade-in across the DOM swap, and it respects prefers-reduced-motion when you follow the recommended patterns.

Loading states. Skeleton screens and progress indicators benefit from subtle looping animation that communicates “the system is working” without demanding attention. Amplitude here matters: a shimmer effect at 20% opacity variation reads as background activity; the same effect at 80% variation competes with real content.

The Design Principle

Behind all of these mechanics sits a single evaluative question: what is this animation communicating?

Hover color changes communicate interactivity — “this element responds to you.” Entrance animations communicate hierarchy and sequence — “this content matters enough to arrive intentionally.” Loading animations communicate state — “something is happening.” Exit animations communicate completion — “this element has been removed from the interaction model.”

When an animation cannot answer that question, it is decoration. Decoration is not inherently wrong, but it carries costs — performance overhead, cognitive load, accessibility risk — that purposeful animation justifies and decoration does not.

The instinct to animate things because you can is worth resisting. The instinct to animate things because they communicate something clearly and efficiently is worth developing. For a deeper look at how motion interacts with type and layout choices, the web fonts loading guide covers a related area where invisible technical decisions produce visible rendering behavior.

Applied well, CSS animations and transitions make interfaces feel considered — responsive to the user, honest about state changes, and clear about what deserves attention. Applied poorly, they make interfaces feel anxious. The mechanics are learnable; the judgment is the harder part.