Motion with a purpose

Movement draws the eye more strongly than color or size. That makes it useful and risky at the same time. Good motion explains something: a panel slides in from the side where its button is, a saved message fades in so you notice it, a button reacts the moment you press it so you know the click registered. Bad motion decorates: everything bounces, nothing settles, and the reader waits for the page instead of the other way around.

CSS has two tools for movement, and both run in the browser without any JavaScript:

  • Transitions smooth out a change that happens anyway: a color on hover, a panel that opens.
  • Keyframe animations play a sequence you describe step by step: a loading spinner, a pulsing marker on an outage map.

A third ingredient, transforms, is not motion by itself, but it is what you usually animate. Keep three rules in mind throughout this page: short (most interface motion takes 150–300 milliseconds), cheap (animate transform and opacity) and optional (respect users who asked for less motion).

Transitions: from one state to another

A CSS transition tells the browser: when the value of this property changes, do not jump to the new value, but move there gradually over a given time. Without a transition, a button that turns darker on hover changes in a single frame. With one, it fades.

<button type="button" class="save">Save meter reading</button>
.save {
  font: inherit;
  padding: 0.6rem 1.2rem;
  border: 2px solid #1d4ed8;
  background: #1d4ed8;
  color: white;
  cursor: pointer;
  transition: background-color 200ms ease-out, transform 200ms ease-out;
}

.save:hover,
.save:focus-visible {
  background-color: #1e3a8a;
  transform: translateY(-2px);
}

.save:focus-visible {
  outline: 3px solid #1e3a8a;
  outline-offset: 3px;
}

.save:active {
  transform: translateY(0);
}

Hover over the button, or press Tab until it has focus: the color fades and the button lifts by two pixels. The transition is declared on the normal state, not on :hover. That way it applies in both directions: when the pointer leaves, the button fades back as smoothly as it changed. The keyboard focus state gets the same treatment, because keyboard users deserve the same feedback (see accessibility basics).

The four parts of a transition

The transition shorthand combines four properties (five with the newer transition-behavior, below). You can write them separately:

Property What it sets Default
transition-property Which properties to animate, separated by commas, or all all
transition-duration How long the change takes, in s or ms 0s (no transition)
transition-timing-function How the speed changes over time (see below) ease
transition-delay How long to wait before starting 0s

In the shorthand, the first time value is the duration and the second one the delay. Several transitions are separated by commas:

/* property, duration, timing function, delay */
.panel {
  transition: opacity 250ms ease-out, transform 250ms ease-out 50ms;
}

What starts a transition

A transition runs whenever the computed value of a listed property changes. The change can come from a pseudo-class such as :hover, :focus-visible or :checked, from a class that JavaScript adds or removes (see the DOM and events), or from a media query that starts to match. CSS itself does not care where the change comes from. This example uses a checkbox and the :has() selector, so it needs no script:

<div class="outage">
  <label>
    <input type="checkbox"> Show details for Leidsche Rijn
  </label>
  <div class="details">
    <p>Power cut since 14:05. A repair crew is on site.</p>
    <p>Expected back: 16:30.</p>
  </div>
</div>
.details {
  margin-top: 0.75rem;
  padding: 0.25rem 1rem;
  border-left: 4px solid #b45309;
  opacity: 0;
  transform: translateY(-0.5rem);
  transition: opacity 250ms ease-out, transform 250ms ease-out;
}

.outage:has(input:checked) .details {
  opacity: 1;
  transform: translateY(0);
}

Tick the box and the details fade in while sliding down half a line; untick it and they fade out. Note that the hidden details still take up space and are still read by screen readers, because opacity: 0 only makes them invisible. For content that should really disappear, the native <details> element (see text and semantic elements) or the hidden attribute is the honest choice.

What can and cannot transition

Only properties whose values lie on a scale can move smoothly: lengths, colors, numbers, percentages and transforms. Properties with separate keyword values, such as display, flip from one value to the other halfway through, or at once. Two newer tools change that: transition-behavior: allow-discrete lets display and similar properties take part in a transition (flipping at the right moment), and @starting-style gives an element that just appeared a value to start from. As of October 2026 both are Baseline newly available (since August 2024), so they work in current browsers, but older ones simply skip the animation.

Animating to height: auto is the classic wish that CSS long could not grant. The interpolate-size property and calc-size() function solve it, but as of 2026 only Chromium-based browsers support them. Until that changes, animate opacity and transform instead, or accept an instant change in height.

Transforms: move, scale and rotate

A transform changes how an element is drawn (its position, size or angle) without changing the space it takes up in the layout. The elements around it do not move. That is exactly why transforms are ideal for animation: the browser does not have to work out the layout of the page again.

<div class="row">
  <div class="tile">none</div>
  <div class="tile move">translate</div>
  <div class="tile turn">rotate</div>
  <div class="tile grow">scale</div>
</div>
.row {
  display: flex;
  flex-wrap: wrap;
  gap: 1rem;
  padding: 1rem 0;
}

.tile {
  width: 5.5rem;
  padding: 1rem 0;
  text-align: center;
  border: 2px solid #0f766e;
  background: #ccfbf1;
}

.move { transform: translate(10px, 20px); }
.turn { transform: rotate(-12deg); }
.grow { transform: scale(1.15); }

The four tiles keep their places in the flex row: the gaps between them stay the same, even though the moved tile sits lower and the scaled one overlaps its neighbor's space. Transform functions you will use most:

  • translate(x, y), translateX(), translateY(): move. A percentage refers to the element's own size, so translateY(-100%) moves an element up by exactly its own height.
  • scale(n): grow or shrink; 1 is the original size.
  • rotate(angle): turn, in deg or turn (0.25turn is 90 degrees).
  • skew(), and 3D functions such as rotateY() and perspective(), for special effects.

Several functions in one transform are applied in order from left to right, each in the coordinate system left by the ones before it, so translateX(50px) rotate(45deg) and rotate(45deg) translateX(50px) end up in different places. transform-origin sets the point the element rotates and scales around; the default is its center.

Modern CSS also has the separate properties translate, rotate and scale. They do the same as the functions, but they can be set and animated independently, so a hover effect that scales does not overwrite a rotation set elsewhere. They have been Baseline widely available since February 2025.

.marker {
  rotate: 45deg;      /* stays */
  transition: scale 150ms ease-out;
}

.marker:hover {
  scale: 1.2;         /* the rotation is kept */
}

Keyframe animations

A CSS animation runs by itself, without waiting for a state change, and can pass through any number of steps, repeat, reverse and pause. You describe the steps once in an @keyframes rule, give it a name, and attach it to elements with the animation property.

<p class="status" role="status">
  <span class="spinner" aria-hidden="true"></span>
  Loading the outage map…
</p>
@keyframes spin {
  from { transform: rotate(0turn); }
  to   { transform: rotate(1turn); }
}

.status {
  display: flex;
  align-items: center;
  gap: 0.75rem;
}

.spinner {
  width: 1.5rem;
  height: 1.5rem;
  border: 3px solid #cbd5e1;
  border-top-color: #1d4ed8;
  border-radius: 50%;
  animation: spin 900ms linear infinite;
}

The spinner is a square with a round border of which one side has a different color; rotating it once per 900 milliseconds, forever, makes the familiar loading circle. The spinner itself is hidden from screen readers with aria-hidden="true", because a spinning shape means nothing when it is read aloud; the text next to it carries the message, and role="status" makes screen readers announce it.

Keyframes and the animation properties

from and to are names for 0% and 100%. You can add steps in between, and several percentages can share one block. This pulse for a fault marker on Kestrel Energy's outage map grows and fades a ring:

<p class="fault">
  <span class="dot" aria-hidden="true"></span>
  Fault reported: Amersfoort-Noord
</p>
@keyframes pulse {
  0%   { transform: scale(1);   opacity: 0.8; }
  70%  { transform: scale(2.4); opacity: 0; }
  100% { transform: scale(2.4); opacity: 0; }
}

.fault {
  display: flex;
  align-items: center;
  gap: 1rem;
  padding: 1.5rem 1rem;
}

.dot {
  position: relative;
  width: 0.9rem;
  height: 0.9rem;
  border-radius: 50%;
  background: #b91c1c;
}

.dot::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: 50%;
  background: #b91c1c;
}

@media (prefers-reduced-motion: no-preference) {
  .dot::after {
    animation: pulse 2s ease-out infinite;
  }
}

Only the pseudo-element (the ring) is animated, so the red dot itself stays still and readable. The endless pulse only runs for readers who have not asked for reduced motion. The animation shorthand combines these properties:

Property Example values Meaning
animation-name pulse Which @keyframes rule to play
animation-duration 2s Length of one cycle
animation-timing-function ease-out, linear Easing, applied per step between keyframes
animation-delay 500ms Wait before the first cycle
animation-iteration-count 3, infinite How many cycles
animation-direction alternate, reverse Play forward, backward, or back and forth
animation-fill-mode forwards, both Keep the first or last keyframe's values before or after the animation runs
animation-play-state paused, running Pause and resume

Without animation-fill-mode: forwards, an element jumps back to its normal style when a one-off animation ends. That is the usual explanation for "the animation works, but at the end it snaps back".

Transition or animation?

Transition Keyframe animation
Starts When a property value changes When the animation is applied (often right away)
Steps Two: the old and the new value As many keyframes as you define
Repeats No Any number of times, or forever
Typical use Hover and focus effects, opening and closing Loading indicators, attention markers, entrances

Timing and easing functions

An easing function (CSS calls it a timing function) maps the time that has passed to how far the movement has progressed. Real objects do not start and stop at full speed; they speed up and slow down, and motion that does the same feels natural.

Four easing curves, each plotting progress (up) against time (to the right). linear is a straight diagonal line: constant speed. ease starts gently, speeds up quickly and slows down gently at the end. ease-in starts flat and rises steeply at the end: it starts slowly and ends fast. ease-out rises steeply first and flattens at the end: it starts fast and ends slowly. linear ease ease-in ease-out time → progress
A steep part of the curve means fast movement, a flat part slow movement. ease-out is steep at the start: quick to react, gentle to settle.

Tick the box below to send four dots across the same distance in the same 1.2 seconds, each with a different easing function. They leave and arrive together, but travel at different speeds on the way:

<div class="race">
  <label><input type="checkbox"> Go</label>
  <div class="lane"><span class="dot linear"></span></div> linear
  <div class="lane"><span class="dot ease"></span></div> ease
  <div class="lane"><span class="dot in"></span></div> ease-in
  <div class="lane"><span class="dot out"></span></div> ease-out
</div>
.race label {
  display: block;
  margin-bottom: 0.75rem;
}

.lane {
  display: inline-block;
  width: 200px;
  margin: 0.4rem 0.5rem 0.4rem 0;
  border-bottom: 1px solid #94a3b8;
  vertical-align: middle;
}

.dot {
  display: block;
  width: 1.25rem;
  height: 1.25rem;
  border-radius: 50%;
  background: #7c3aed;
  transition: transform 1.2s;
}

.linear { transition-timing-function: linear; }
.ease   { transition-timing-function: ease; }
.in     { transition-timing-function: ease-in; }
.out    { transition-timing-function: ease-out; }

.race:has(input:checked) .dot {
  transform: translateX(180px);
}

The keywords and when to use them:

  • ease-out: fast start, soft landing. Best for things that enter or respond to the user (menus, tooltips, hover effects), because the reaction is immediate.
  • ease-in: slow start, fast end. Suits things that leave the screen; for anything else it feels sluggish.
  • ease-in-out: slow at both ends, for movement from one place on screen to another.
  • ease (the default) is similar to ease-in-out, but faster at the start.
  • linear: constant speed. Looks mechanical for movement, but is right for things that keep turning, such as a spinner, and for color changes.

For full control, cubic-bezier(x1, y1, x2, y2) defines your own curve with two control points (all keywords above are shorthands for such curves; ease-out is cubic-bezier(0, 0, 0.58, 1)), and steps(n) jumps in n equal steps instead of moving smoothly, which is useful for a typing effect or a sprite sheet. The linear() function draws a curve through any number of points, which allows bounce and spring effects in pure CSS; it has been Baseline widely available since June 2026.

.toast {
  /* a custom, snappy ease-out */
  transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}

.typing {
  /* 20 discrete steps: no in-between frames */
  animation: type 2s steps(20) forwards;
}

What to animate: smooth and cheap

For smooth motion, a browser has to produce a new frame about 60 times per second (more on screens with a higher refresh rate), which leaves roughly 16 milliseconds per frame. How much work each frame costs depends on which property you animate, because a change can force the browser to redo several steps of drawing the page:

The four steps a browser takes to show a change: style, layout, paint and composite. Changing width, top or margin starts at layout, so layout, paint and composite all run again. Changing color or background starts at paint, so paint and composite run again. Changing transform or opacity usually needs only the composite step. Style Layout Paint Composite a change runs: width, top, margin color, background transform, opacity
The further left a change starts, the more work per frame. Animating transform and opacity usually skips layout and paint altogether.
  • Layout works out the size and position of every box. Changing width, height, top, left, margin or font-size can move other elements too, so the browser recalculates the layout of (part of) the page on every frame.
  • Paint fills in the pixels: colors, borders, shadows, text. Changing color, background-color or box-shadow repaints, which is cheaper than layout but not free for large areas.
  • Composite puts the painted layers together, often on the graphics processor. When an element is on its own layer, which browsers usually arrange for an element whose transform or opacity is being animated, a change only shifts or fades that layer.

In practice: to move something, animate transform: translate() rather than left or margin; to make something appear, animate opacity and a small translate rather than height. The result on screen can look the same, but on a cheap phone one version is smooth and the other stutters.

/* Avoid: layout on every frame */
.drawer       { left: -20rem; transition: left 300ms ease-out; }
.drawer.open  { left: 0; }

/* Prefer: composite only */
.drawer       { transform: translateX(-100%); transition: transform 300ms ease-out; }
.drawer.open  { transform: translateX(0); }

Respecting reduced motion

For some people, motion on screen is not a matter of taste. Large movements, parallax effects and zooming can cause dizziness, nausea or headaches in people with vestibular (inner-ear balance) disorders, and constant movement makes it hard for others to concentrate on the text. Every major operating system therefore has a setting to reduce motion (in Windows "Animation effects", in macOS and iOS "Reduce motion", in Android "Remove animations"), and the browser passes it on to CSS as the prefers-reduced-motion media feature. It has two values: reduce (the user turned the setting on) and no-preference.

The safest pattern is "no motion first": write the motion only inside a media query that checks the user has not asked for less. A browser that does not know the feature, and every user who asked for calm, then simply get a still page.

<p class="status" role="status">
  <span class="spinner" aria-hidden="true"></span>
  Sending your meter reading…
</p>
.status {
  display: flex;
  align-items: center;
  gap: 0.75rem;
}

.spinner {
  width: 1.5rem;
  height: 1.5rem;
  border: 3px solid #cbd5e1;
  border-top-color: #1d4ed8;
  border-radius: 50%;
}

@keyframes spin {
  to { transform: rotate(1turn); }
}

@media (prefers-reduced-motion: no-preference) {
  .spinner {
    animation: spin 900ms linear infinite;
  }
}

If your system has reduced motion switched on, the circle in the result stands still and the text still tells you what is happening. Switch the setting off (or on) and the result changes at once. The @keyframes rule itself can stay outside the media query: it does nothing until an element uses it.

Reduced motion does not mean no change at all. Replace big movement with something calm: a fade instead of a slide, an instant change instead of a zoom. A larger site can also do it the other way around and switch motion off globally:

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

The very short duration (instead of none) keeps the end state of animations and the events that scripts may wait for, while removing the visible movement. This site uses both approaches: its smooth scrolling and small transitions only run when your system does not ask for reduced motion.

Motion in WCAG

Three success criteria of WCAG deal with movement:

  • 2.2.2 Pause, Stop, Hide (level A): content that moves, blinks or scrolls by itself for more than five seconds, alongside other content, needs a way to pause, stop or hide it. An endless decorative animation fails this; a spinner that is only shown while something loads is part of a process and is generally acceptable.
  • 2.3.1 Three Flashes or Below Threshold (level A): nothing may flash more than three times in any one-second period, unless the flashes are below the general and red flash thresholds, because flashing can trigger seizures.
  • 2.3.3 Animation from Interactions (level AAA): motion triggered by an interaction can be switched off, unless it is essential. Respecting prefers-reduced-motion is the usual way to meet it.

Newer motion tools

Transitions and keyframe animations have worked in every browser for about a decade (both Baseline widely available since 2018). A few newer features build on them. Their support differs, so check the status before you depend on one (see custom properties and modern CSS for how Baseline works); as of October 2026:

Feature What it does Status (October 2026)
View transitions (same document) Animate between two states of a page, such as a list changing into a detail view: the browser takes a snapshot of the old and new state and cross-fades or morphs between them. Started from JavaScript with document.startViewTransition(). Baseline newly available since October 2025
Cross-document view transitions The same between two separate pages of one site, switched on in CSS with @view-transition { navigation: auto; } Limited: Chromium-based browsers and Safari, not Firefox
@starting-style, transition-behavior Entry animations for elements that just appeared, and transitions of display Baseline newly available since August 2024
Scroll-driven animations Tie an animation's progress to the scroll position instead of time (animation-timeline: scroll()), for reading-progress bars and reveal effects Limited: Chromium-based browsers and Safari 26, not Firefox

Each of these is a progressive enhancement: a browser without support shows the change instantly, so the page still works. That is the right way to use them, and the reduced-motion rules above apply to them just as much.