@keyframes and prefers-reduced-motion done right
By Flavio Copes
Build a CSS keyframes animation step by step, understand fill modes and per-keyframe timing, and respect prefers-reduced-motion properly.
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. 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:
@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:
.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:
.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 startsforwards— keep the last keyframe’s styles after the animation endsboth— 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:
@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:
@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 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:
@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:
@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:
@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: smoothis 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. 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.
Related posts about css: