ehsan.blog
~/blog/how-to-respect-prefers-reduced-motion — zsh
cat how-to-respect-prefers-reduced-motion.md

How to respect prefers-reduced-motion without breaking your layout

·5 min read

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.

css
@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:

css
/* 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.01ms makes animations effectively instant.
  • animation-iteration-count: 1 stops anything set to loop forever from looping — no more pulsing or endlessly repeating effects.
  • transition-duration: 0.01ms snaps 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%:

css
/* 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:

css
/* 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:

css
/* 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 collapsing animation-duration/transition-duration to ~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: none decorative-only effects — no content is lost.
ls ./related
cat ./comments

Comments are not configured yet. Enable GitHub Discussions and paste the giscus repo-id / category-id into src/consts.ts.