Respecting Reduced Motion in Theme Switches

Part of Theme Transition Motion & Performance. The task is to make a theme change instant — not merely faster — for visitors who have asked their operating system to reduce motion, and to make that behaviour impossible to lose in a refactor.

Correct and incorrect responses to a reduced-motion preference Three treatments of the same theme change under reduced motion: a shortened fade and a subtler fade are both marked incorrect, while an instant swap with no intermediate frames is marked correct. What a reduced-motion visitor should get when the theme changes Shorter fade 220ms becomes 80ms still a large-area luminance movement not what was asked for Subtler fade lower opacity delta the movement is smaller but it is still movement not what was asked for Instant swap one frame, no intermediate states are rendered the feature still works fully correct
Reduced motion is a request to avoid a class of movement, not a request for less of it. A shorter animation is still an animation.

Prerequisites Link to this section

  • A theme toggle that performs a value swap on a root attribute, as described in runtime theme switching.
  • Any theme animation — a CSS transition, a view transition, or both — currently in place.
  • A test runner able to emulate media features; Playwright’s emulateMedia is used below.
  • Access to the stylesheet where theme transitions are declared, and to the toggle handler.

Step-by-Step Implementation Link to this section

Step 1 — Make no motion the default in CSS Link to this section

Intent: declare transitions inside a positive media query rather than declaring them unconditionally and unwinding them later.

/* Motion is opt-in, not opt-out. */
@media (prefers-reduced-motion: no-preference) {
  html.theme-changing body,
  html.theme-changing .site-header,
  html.theme-changing .card {
    transition: background-color 180ms ease, border-color 180ms ease;
  }
}

Why this works: the common alternative — declaring the transition globally and then setting animation: none inside @media (prefers-reduced-motion: reduce) — depends on the override rule being present, correctly scoped, and more specific than every transition declaration in the codebase. Any of those can fail silently as the stylesheet grows. Inverting the default means a missing rule results in no motion, which is the safe direction to fail in.

Step 2 — Branch in the toggle handler before choosing a mechanism Link to this section

Intent: never enter the animation code path at all when the preference is set, so no snapshot is captured and no class is added.

const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');

function commitTheme(next) {
  document.documentElement.setAttribute('data-theme', next);
  try { localStorage.setItem('theme', next); } catch (_) {}
}

export function setTheme(next) {
  if (reducedMotion.matches) {
    commitTheme(next);          // instant: no class, no view transition
    return;
  }
  if (document.startViewTransition) {
    document.startViewTransition(() => commitTheme(next));
    return;
  }
  const root = document.documentElement;
  root.classList.add('theme-changing');
  commitTheme(next);
  setTimeout(() => root.classList.remove('theme-changing'), 200);
}

Why this works: the guard is first, so the two animation mechanisms are unreachable under reduced motion. That matters beyond correctness: a view transition freezes the page for the duration of the snapshot, and skipping it entirely means the reduced-motion path is also the fastest path.

Step 3 — Read the preference live, not once at startup Link to this section

Intent: a visitor can change the system setting while the page is open, and the toggle must respect the new value immediately.

// Correct: matchMedia returns a live object; .matches is re-read on access.
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');

// Wrong: freezes the value at load time.
// const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

// Optional: react to a change, e.g. to update a settings UI.
reducedMotion.addEventListener('change', (event) => {
  document.documentElement.dataset.motion = event.matches ? 'reduced' : 'full';
});

Why this works: the MediaQueryList object stays connected to the underlying media state, so reading .matches at the moment of the toggle always gives the current answer. Destructuring .matches into a constant at module load is the single most common way this requirement is broken, and it is invisible in testing because tests rarely change the preference mid-session.

Two independent guards protecting the reduced-motion path A script guard and a CSS guard shown side by side, each with the failure it catches, and a note that either guard alone leaves a gap. Script guard skips the view transition entirely never adds the transition class catches: the API path misses: a stale cached stylesheet CSS guard transition rules never match independent of any script catches: a script that failed misses: the view transition API Each guard covers the other's blind spot. Removing either one during a refactor leaves a reachable path with motion.
The duplication is deliberate: the script cannot suppress a CSS transition it never triggered, and CSS cannot suppress a view transition the script started.

Step 4 — Suppress the view transition animation in CSS as well Link to this section

Intent: cover the case where a view transition was started before the preference changed, or where the script and stylesheet are out of sync because one was served from cache.

@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) {
    animation: none !important;
  }
}

Why this works: setting the animation to none on the view-transition pseudo-elements makes the transition resolve immediately, so the DOM update still commits and the visitor still gets their theme — they simply never see the intermediate frames. This is one of the few places where !important is justified: user-agent animations on these pseudo-elements are declared with high precedence, and the rule exists specifically to override them.

Step 5 — Keep the preference out of the theme decision Link to this section

Intent: reduced motion must not change which theme a visitor gets, only how the change is presented.

// Wrong: conflates two unrelated preferences.
if (reducedMotion.matches) setTheme('light');

// Right: motion preference affects presentation only.
if (reducedMotion.matches) commitTheme(next);

Why this works: the two media features answer different questions. A visitor who has reduced motion enabled has said nothing about colour, and quietly overriding their theme is both surprising and, if they chose dark mode for visual comfort, actively unhelpful. The same separation applies to prefers-contrast and forced-colors, which have their own correct responses covered in forced-colors and high contrast mode.

Verification Link to this section

Three assertions cover the behaviour: no animation under reduced motion, the theme still changes, and the preference is read at toggle time rather than at load.

// tests/reduced-motion.spec.mjs
import { test, expect } from '@playwright/test';

test('no transition runs under reduced motion', async ({ page }) => {
  await page.emulateMedia({ reducedMotion: 'reduce' });
  await page.goto('/');

  const animations = [];
  await page.exposeFunction('__record', (n) => animations.push(n));
  await page.evaluate(() => {
    document.addEventListener('transitionstart', (e) => window.__record(e.propertyName));
  });

  await page.getByRole('button', { name: /dark theme/i }).click();
  await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
  await page.waitForTimeout(300);

  expect(animations).toEqual([]);            // the theme changed, nothing animated
});

test('preference changes mid-session are honoured', async ({ page }) => {
  await page.goto('/');                       // load with motion allowed
  await page.emulateMedia({ reducedMotion: 'reduce' });   // then change it
  const transitions = [];
  await page.exposeFunction('__record', (n) => transitions.push(n));
  await page.evaluate(() => {
    document.addEventListener('transitionstart', (e) => window.__record(e.propertyName));
  });
  await page.getByRole('button', { name: /dark theme/i }).click();
  await page.waitForTimeout(300);
  expect(transitions).toEqual([]);            // fails if .matches was cached at load
});

The second test is the valuable one. The first passes even with a cached preference, because the page was loaded with the preference already set. Only the second catches the destructured-constant mistake described in step 3, which is why it is worth the extra few lines.

Motion preference audit across the interface A checklist of interface motion grouped into three categories: large-area motion that must be removed, functional motion that should be preserved, and instant feedback that is unaffected. Remove theme cross-fade page transitions parallax and auto-scroll large slide-in panels large-area movement Keep, but shorten focus ring appearance menu open and close loading indicators tooltip reveal communicates state Unaffected colour change on hover opacity on disabled instant state changes text updates no perceived movement
Reduced motion is not a request to remove every transition. Motion that communicates a state change is often more important to keep than to remove.

Troubleshooting Link to this section

Symptom Likely cause Fix
The fade still runs with the preference set .matches was read into a constant at module load Keep the MediaQueryList object and read .matches at the moment of the toggle
The theme does not change at all under reduced motion The guard returns before committing rather than after The guard must call the commit function, not skip it
A view transition still animates Only the script guard is present Add the CSS rule from step 4; the pseudo-element animations are user-agent declared
Everything in the product stops animating The reduced-motion block disables all transitions globally Scope the suppression to large-area motion; state-communicating transitions should survive
The test passes locally and fails in CI The CI browser has no media emulation configured Set the preference explicitly with emulateMedia rather than relying on the environment

Why the Instant Swap Is Also the Better Default Link to this section

There is a pragmatic argument for this behaviour that has nothing to do with compliance. The reduced-motion path is the simplest code path in the toggle: no snapshot, no class, no timer, no cleanup. It cannot fail to remove a class, cannot leave a transition attribute behind, and cannot be interrupted halfway. Every bug in a theme toggle lives in the animation path.

That suggests a useful design discipline: build the instant swap first, ship it, and add the animation afterwards as an enhancement that is provably skippable. Teams that build the animation first tend to end up with a toggle whose correctness depends on the animation completing, which is exactly the coupling that produces the “theme stuck halfway” reports.

It also clarifies what the animation is for. If the product works, feels responsive, and looks correct with the instant swap — and it should — then the fade is a polish detail with a measurable cost, evaluated on its own merits. That is a healthier framing than treating the animation as the feature and the instant path as a degraded fallback for a minority of visitors.

One last point on scope. The preference applies to the theme change itself, not to the theme. A visitor with reduced motion enabled should still get the toggle, still get their stored choice honoured, and still see exactly the same interface as anyone else once the swap has happened. The only difference is that they never see the frames in between.

Migration Note Link to this section

Retrofitting this into a product that already animates is a two-pass job. The first pass is mechanical: find every transition and animation declaration and decide, for each, which of the three categories in the figure above it belongs to. Most fall into the third category and need no change at all.

The second pass is the audit that produces value. Large-area motion is usually concentrated in a handful of places — page transitions, a theme fade, a hero animation, a drawer — and those are where the reduced-motion work actually is. Handling those four cases well is worth far more than mechanically wrapping every transition in a media query, which tends to produce an interface that feels broken under the preference because state changes stop being visible.

If the codebase already has a global @media (prefers-reduced-motion: reduce) { * { animation: none } } rule, treat removing it as part of this work. That rule is a blunt instrument: it suppresses the loading spinner along with the parallax, and the resulting confusion is a genuine accessibility regression for the visitors it was meant to help.