Keyframe animations and reduced motion

Build multi-step animations with @keyframes, control them with animation properties, and respect users who ask for less motion.

  • Beginner
  • 7 min read
  • Updated 17 Oct 2026

What you will learn

  • Write a keyframe animation
  • Control duration, repetition, direction and fill mode
  • Pause and play animations
  • Disable motion for users who ask

Before you start

A transition animates between two states when something changes. A keyframe animation runs on its own, through as many steps as you define, and can repeat. Use it for loading indicators, attention cues and entrance effects.

Defining keyframes

@keyframes describes the steps. Give it a name, then list the styles at points in the timeline:

CSS
@keyframes fade-in-up {
    from {
        opacity: 0;
        transform: translateY(1rem);
    }

    to {
        opacity: 1;
        transform: translateY(0);
    }
}

from means 0% and to means 100%. For more steps, use percentages:

CSS
@keyframes pulse {
    0%,
    100% {
        transform: scale(1);
    }

    50% {
        transform: scale(1.08);
    }
}

Several percentages can share one block, as 0% and 100% do here. If you leave out from or to, the browser uses the element's current styles for that end.

Applying an animation

Keyframes do nothing until an element uses them:

CSS
.notice {
    animation-name: fade-in-up;
    animation-duration: 400ms;
    animation-timing-function: ease-out;
}

Or with the shorthand:

CSS
.notice {
    animation: fade-in-up 400ms ease-out;
}

The animation starts as soon as the element is rendered with that rule.

Animation properties

PropertyExampleMeaning
animation-namepulseWhich @keyframes to run
animation-duration2sLength of one cycle
animation-timing-functionease-in-outSpeed curve, applied between each pair of keyframes
animation-delay200msWait before starting; negative values start part-way through
animation-iteration-count3 or infiniteHow many cycles
animation-directionalternatenormal, reverse, alternate or alternate-reverse
animation-fill-modebothWhether styles apply before and after running
animation-play-statepausedPause or resume
CSS
.loader {
    animation: spin 1s linear infinite;
}

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

linear suits continuous rotation; an easing curve would make the spinner speed up and slow down every turn.

fill-mode: the most misunderstood property

By default, an animation affects the element only while it runs. Before it starts (during a delay) and after it ends, the element shows its normal styles. That causes two classic glitches:

  1. With a delay, the element appears fully visible, then disappears, then fades in.
  2. After the animation ends, the element jumps back to its original state.

animation-fill-mode fixes both:

ValueBefore startAfter end
none (default)Normal stylesNormal styles
forwardsNormal stylesKeeps the last keyframe
backwardsApplies the first keyframeNormal styles
bothApplies the first keyframeKeeps the last keyframe
CSS
.card {
    animation: fade-in-up 400ms ease-out both;
}

.card:nth-child(2) { animation-delay: 80ms; }
.card:nth-child(3) { animation-delay: 160ms; }

With both, each card stays hidden until its delay ends, then fades in and stays visible. This staggered entrance is a common pattern.

Prefer designing keyframes so the final state matches the element's normal styles. Then you rarely need forwards, and the element is correct even if the animation never runs.

Pausing and controlling animations

CSS
.marquee {
    animation: scroll-left 20s linear infinite;
}

.marquee:hover,
.marquee:focus-within {
    animation-play-state: paused;
}

Anything that moves for more than five seconds should have a way to pause it. That is a WCAG requirement (success criterion 2.2.2, Pause, Stop, Hide), and pausing on hover alone is not enough for keyboard or touch users. Provide a real pause control, or avoid long-running motion.

You can toggle a class from JavaScript to start an animation on demand:

CSS
.shake {
    animation: shake 300ms ease-in-out;
}

@keyframes shake {
    25% { transform: translateX(-4px); }
    75% { transform: translateX(4px); }
}

Adding the .shake class to an input plays the animation once. To replay it, remove the class and add it again on a later frame.

Performance

The same rule as transitions applies: animate transform and opacity where possible. They can usually run on the compositor without recalculating layout. Animating width, top or margin makes the browser redo layout on every frame, which can cause jank on slower devices.

Respecting prefers-reduced-motion

People with vestibular disorders, migraines or attention difficulties can be badly affected by motion on screen. Operating systems let users ask for reduced motion, and CSS can read the setting.

There are two approaches. The first adds motion only for people who have not asked for less:

CSS
@media (prefers-reduced-motion: no-preference) {
    .card {
        animation: fade-in-up 400ms ease-out both;
    }
}

Users who set "reduce motion" never get the animation, and the cards simply appear. This motion-as-enhancement approach is the safest default.

The second removes or tones down motion when asked:

CSS
.loader {
    animation: spin 1s linear infinite;
}

@media (prefers-reduced-motion: reduce) {
    .loader {
        animation-duration: 3s;   /* slower, calmer */
    }

    .marquee {
        animation: none;
    }
}

"Reduced" does not have to mean "none". A loading indicator still needs to show that something is happening. Replacing movement with a gentle fade, or slowing it right down, often works better than removing it.

You may see a global rule that sets every animation and transition duration to 0.01ms under prefers-reduced-motion: reduce. It is a blunt instrument: it can break JavaScript that waits for animationend, and it removes harmless fades along with harmful movement. Decide per component where you can.

Testing reduced motion

You do not need to change your system settings. In Chrome and Edge DevTools, open the command menu (Ctrl+Shift+P, or Cmd+Shift+P on macOS) and run "Emulate CSS prefers-reduced-motion: reduce". Firefox and Safari have similar options in their responsive design or developer settings. The Web Accessibility Essentials course covers motion and vestibular disorders in more depth.

Debugging animations

  • Nothing happens: check that animation-name matches the @keyframes name exactly. Names are case-sensitive.
  • Jumps back at the end: add animation-fill-mode: forwards or both, or make the final keyframe match the normal style.
  • Flash before a delayed start: use backwards or both.
  • Inspect frame by frame: the Animations panel in Chrome and Edge, and the Animations section of Firefox's Inspector, show each animation on a timeline with scrubbing and slow-motion replay.

Summary

  • @keyframes defines steps with from, to or percentages; animation applies them.
  • Control cycles with duration, iteration count, direction and delay.
  • animation-fill-mode: both keeps the first keyframe during a delay and the last after the end.
  • Anything moving for more than five seconds needs a way to pause it.
  • Treat motion as an enhancement with prefers-reduced-motion, and reduce rather than remove where motion carries meaning.

Further reading