# @keyframes and prefers-reduced-motion done right

> Build a CSS keyframes animation step by step, understand fill modes and per-keyframe timing, and respect prefers-reduced-motion properly.

Author: [Flavio Copes](https://flaviocopes.com/about/) | Published: 2026-08-05 | Topics: [CSS](https://flaviocopes.com/tags/css/) | Canonical: https://flaviocopes.com/css-keyframes-reduced-motion/

CSS animations have two halves: the `@keyframes` rule that describes the motion, and the `animation` property that applies it to an element.

I covered the fundamentals in [a CSS animations tutorial](https://flaviocopes.com/css-animations/). This post goes deeper: we'll build an animation step by step, look at the parts people get wrong (fill modes, per-keyframe timing), and then do the part almost everyone skips — making it respect `prefers-reduced-motion`.

## Building a fade-up, step by step

Let's build the most useful animation on the web: an element that fades in while sliding up. Cards, modals, toasts — they all use some version of this.

Start with the keyframes. Two stops, from and to:

```css
@keyframes fade-up {
  0% {
    transform: translate(0px, 20px) scale(0.95);
    opacity: 0;
  }
  100% {
    transform: none;
    opacity: 1;
  }
}
```

At the start, the element sits 20px lower, slightly smaller, and invisible. At the end it's in its natural place, full size, fully visible.

Notice we only animate `transform` and `opacity`. These two properties are the cheap ones: the browser can animate them on the GPU without recalculating layout. Animating `top`, `height` or `margin` forces layout work on every frame, and that's how animations get janky.

Now apply it:

```css
.card {
  animation: fade-up 600ms ease-out both;
}
```

The shorthand packs name, duration, easing, and fill mode. `ease-out` is the right default for entrances: fast arrival, gentle settle.

## Fill modes: the part everyone gets wrong

Run the animation above without `both` and watch what happens with a delay:

```css
.card {
  animation: fade-up 600ms ease-out 2s;
}
```

For those 2 seconds of delay, the card is **fully visible**. Then it blinks to invisible, and fades up. Ugly.

Why? Because outside the animation, the element just has its normal styles. The keyframes only apply *while the animation runs*.

`animation-fill-mode` fixes that:

- `backwards` — apply the first keyframe during the delay, before the animation starts
- `forwards` — keep the last keyframe's styles after the animation ends
- `both` — do both

For entrance animations, `both` is what you want. The element starts hidden (even during the delay) and stays put when done.

My advice: any time an animation has a delay, or animates `opacity`, reach for `both`. It's the fill mode you want 90% of the time.

## Timing per keyframe

Here's something less known: `animation-timing-function` doesn't ease the whole animation. It eases **each segment between keyframes**.

You can even override it inside a specific keyframe:

```css
@keyframes bounce {
  0% {
    transform: translateY(0);
    animation-timing-function: ease-out;
  }
  40% {
    transform: translateY(-30px);
    animation-timing-function: ease-in;
  }
  100% {
    transform: translateY(0);
  }
}
```

The way up decelerates (`ease-out`, set at 0%, applies from 0% to 40%). The way down accelerates (`ease-in`, applies from 40% to 100%). That's gravity. Without the per-keyframe easing, the bounce feels floaty and wrong.

The timing function in a keyframe controls the segment *leaving* that keyframe. Setting one on the last keyframe does nothing.

Multi-stop animations like shake follow the same idea — several keyframes, small transform changes at each:

```css
@keyframes shake {
  20% { transform: translateX(-8px); }
  40% { transform: translateX(8px); }
  60% { transform: translateX(-6px); }
  80% { transform: translateX(6px); }
}
```

The 0% and 100% keyframes can be omitted: the browser fills them in with the element's normal styles.

If you want to experiment with keyframe stops, timing and fill modes without hand-editing CSS, I built a [keyframes generator](https://flaviocopes.com/tools/keyframes-generator/) that lets you tweak everything visually and copies out the full CSS — including the reduced motion part we're about to write.

## The accessibility part: prefers-reduced-motion

Some people get dizzy, nauseous or disoriented from on-screen motion. It's called a vestibular disorder, and it's common enough that every operating system ships a "reduce motion" setting.

CSS exposes that setting as a media query:

```css
@media (prefers-reduced-motion: reduce) {
  .card {
    animation: none;
  }
}
```

That's the minimum viable version: if the user asked for reduced motion, don't animate.

If you'd rather flip the logic, only apply the animation when the user has *no* preference:

```css
@media (prefers-reduced-motion: no-preference) {
  .card {
    animation: fade-up 600ms ease-out both;
  }
}
```

This version is safer, because the animation is opt-in. Forget the media query on one element and that element simply doesn't animate — instead of animating for someone who asked you not to.

## What "reduced" should mean

Here's the nuance: the setting is called reduced motion, not *no* motion.

The problem is movement — things sliding, zooming, spinning, parallaxing. A fade doesn't move anything. So instead of killing the animation entirely, the better approach is often to replace motion with opacity:

```css
@media (prefers-reduced-motion: reduce) {
  .card {
    animation: fade-only 400ms ease-out both;
  }
}

@keyframes fade-only {
  from { opacity: 0; }
  to { opacity: 1; }
}
```

The element still appears gracefully. It just doesn't fly in.

Two more things belong under this media query:

- **infinite animations**: spinners are fine (they signal loading), but decorative looping animations should stop
- **smooth scrolling**: `scroll-behavior: smooth` is motion too, disable it here

Be careful with one detail: `animation: none` on an element that relies on `fill-mode: forwards` for its final state can leave it stuck in its *initial* state. If your element starts at `opacity: 0` in CSS and animates to visible, removing the animation makes it invisible forever. Test with the setting on — on macOS it's under Accessibility → Display → Reduce motion, and browser DevTools can emulate it.

For a wider look at the animation options beyond CSS keyframes, see [comparing the options for animations on the web](https://flaviocopes.com/animations/). But whatever technique you pick, the reduced motion query applies the same: motion is a preference, and CSS gives you a one-line way to respect it.
