Transitions and Animations
Movement in CSS: transitions between states, keyframe animations, transforms, timing functions, what to animate for smooth performance, and respecting users who prefer reduced motion.
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, sotranslateY(-100%)moves an element up by exactly its own height. -
scale(n): grow or shrink;1is the original size. -
rotate(angle): turn, indegorturn(0.25turnis 90 degrees). -
skew(), and 3D functions such asrotateY()andperspective(), 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.
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 toease-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:
-
Layout works out the size and position of every
box. Changing
width,height,top,left,marginorfont-sizecan 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-colororbox-shadowrepaints, 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
transformoropacityis 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-motionis 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.