How to respect prefers-reduced-motion without breaking your layout
Motion isn’t free. Parallax, glitch effects, and things that fly in can trigger dizziness, nausea, or migraines for people with vestibular disorders. Operating systems let those users say “please, less motion,” and the browser exposes that choice to CSS. Honoring it is a few lines of code — but there’s a subtle trap that can leave your content invisible if you’re not careful. Let me walk through both.
What the media query actually means
prefers-reduced-motion is a media feature, like min-width, except it reflects
an OS accessibility setting instead of screen size. When a user turns on
“Reduce motion” (macOS Accessibility, Windows Animation settings, etc.), the
value becomes reduce.
@media (prefers-reduced-motion: reduce) {
/* rules that only apply when the user asked for less motion */
}Rules inside this block apply only to those users. Everyone else sees your full animated site untouched. So the strategy is: build your animations normally, then add one block that neutralizes them for people who opted out.
The blunt instrument: kill all durations
The simplest, most effective first move is to collapse every animation and transition to near-zero time. Here’s the top of my real block:
/* src/index.css */
@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;
}
/* ... */
}Applying this to every element and pseudo-element (*, *::before,
*::after) means anything you forgot is covered too. Three things happen:
animation-duration: 0.01msmakes animations effectively instant.animation-iteration-count: 1stops anything set to loop forever from looping — no more pulsing or endlessly repeating effects.transition-duration: 0.01mssnaps hover/state transitions instead of gliding them.
The !important is deliberate here. Normally I’d avoid it, but this block has
to win over whatever timing your component styles set, so it earns the override.
The trap: animations that build your layout
Here’s the subtle part, and the reason a naive “turn off all animations” can backfire. Some animations don’t just decorate — they produce the final state of your content. If you simply cancel them, the element gets stuck at the animation’s starting frame.
Two examples from my site. A skill bar
fills from width: 0 to a target width, and a typewriter line grows from
width: 0 to 100%:
/* src/index.css — the normal animated versions */
.skill-fill {
animation: fillBar 1.4s cubic-bezier(0.22, 1, 0.36, 1) forwards;
width: 0; /* starts empty */
}
.typing-effect {
overflow: hidden;
white-space: nowrap;
animation: typing 2s steps(40, end) forwards;
border-right: 2px solid oklch(0.92 0 0);
}Both start at width: 0. If reduced-motion just sets
animation-duration: 0.01ms, in some rendering paths the element can end up
showing its starting value — an empty bar, or text clipped to zero width. The
user asked for less motion, not for your content to disappear.
The fix: land on the FINAL values by hand
So inside the same media query, I explicitly force these elements to their finished state and remove the animation entirely:
/* src/index.css */
@media (prefers-reduced-motion: reduce) {
/* Sizes that are produced by keyframes must land at their final value */
.skill-fill {
width: var(--target-w) !important;
animation: none !important;
}
.typing-effect {
width: 100% !important;
border-right: none !important;
}
/* ... */
}The skill bar jumps straight to var(--target-w) (the same variable the
keyframes were animating toward), and the typing line to width: 100% with the
fake cursor border removed. Now a reduced-motion user sees a full skill bar and
the complete text — instantly, no motion. That’s the whole principle: for any
animation that reveals content, set the element to the animation’s end state
in the reduced-motion block.
Purely decorative effects: just remove them
Some effects add nothing to comprehension — the glitch, the scanline overlay, the CRT texture. Those I delete outright:
/* src/index.css */
@media (prefers-reduced-motion: reduce) {
/* Decorative-only effects: remove entirely */
.glitch::before,
.glitch::after,
.scanlines::after,
.crt-scanlines {
display: none !important;
}
}display: none on the glitch clones and scanline overlays removes them
completely. There’s no content loss because those pseudo-elements were pure
visual flair — the real heading text underneath is still there and readable.
The mental checklist
When you write the reduced-motion block, sort every animated thing into one of two buckets:
- Decorative (glitch, scanlines, floating shapes): remove or freeze it.
- Content-revealing (fills, typewriters, fade-in-and-stay): pin the element to its final size/opacity so the content is fully visible without moving.
Get that sorting right and you deliver a calm, complete page to the people who need one — without stripping anything they actually came to read.
What to remember
@media (prefers-reduced-motion: reduce)targets users who enabled the OS “reduce motion” setting; build animations normally, then neutralize them here.- A global
*rule collapsinganimation-duration/transition-durationto ~0ms and iteration count to 1 handles the bulk. - Animations that create layout (width from 0, opacity from 0) must be forced to their FINAL value in the block, or content stays invisible.
- Reuse the same target (
var(--target-w),100%) the keyframes aimed at. display: nonedecorative-only effects — no content is lost.