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.
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
emulateMediais 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.
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.
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.
Related Link to this section
- Theme Transition Motion & Performance — the parent area covering when theme animation is worth having at all
- Animating Theme Changes with View Transitions — the animation path this guide gates
- Supporting Windows High Contrast with forced-colors — the neighbouring preference with its own correct response