Testing prefers-reduced-motion Across Operating Systems and Browsers

A prefers-reduced-motion override that was only ever checked in one browser’s DevTools is untested code. The preference travels through a chain — OS toggle, engine accessibility API, media query evaluation, cascade — documented in the motion preference support matrix, and each link can fail independently. This page turns that chain into a layered testing workflow: fast DevTools emulation while developing, real OS toggles per platform before release, matchMedia mocks and Playwright’s reducedMotion option in the automated suite, and a CI gate that keeps regressions out. It sits within the Accessibility & Inclusive Motion Standards section.

When to use this approach

Use the full four-layer workflow when your site ships scroll-driven animations, View Transitions, or any motion controlled by the override patterns from the prefers-reduced-motion implementation guide. Scale it down deliberately, not accidentally:

  • DevTools emulation only — acceptable while iterating on a single component’s CSS. Fastest feedback; exercises the media query and change events but nothing upstream of the engine.
  • Emulation + real OS toggles — the minimum bar before shipping motion-heavy work. Real toggles are the only way to verify the OS-to-engine link, WebKit-shell behavior on iOS, and Android’s animator-scale quirk.
  • Automated layers (mocks + Playwright) — required once more than one person touches the codebase. Manual verification decays; a test that asserts “zero running animations under reduce” does not.
  • CI gate — required for teams. It converts reduced-motion support from a review-time courtesy into a build-time invariant.

The layers are ordered by cost and by how much of the chain they cover. None replaces the others: emulation cannot prove OS integration, and a real iPhone cannot run on every pull request.


Layered prefers-reduced-motion testing pipeline A vertical pipeline of four stages: DevTools emulation for second-level feedback while coding, real operating-system toggles on macOS, Windows, iOS, and Android before release, automated tests using matchMedia mocks and Playwright reducedMotion emulation, and a CI gate that runs on every pull request. A feedback arrow on the right returns from the CI gate to DevTools emulation, showing that caught regressions restart the loop. 1. DevTools emulation Rendering tab — seconds per check while coding 2. Real OS toggles macOS, Windows, iOS, Android before release 3. Automated tests matchMedia mocks + Playwright every change 4. CI gate runs on every pull request every merge failures restart the loop Each layer covers more of the OS-to-cascade chain than the one above it

Implementation

Step 1: Emulate the preference in the DevTools Rendering tab

In Chrome or Edge, open DevTools, press Ctrl/Cmd+Shift+P, run Show Rendering, and set Emulate CSS media feature prefers-reduced-motion to reduce. The override is per-tab, lasts while DevTools is open, and fires MediaQueryList change events — so both your CSS and any JavaScript listeners react exactly as they would to a real OS flip.

While the emulation is active, run the two assertions that catch nearly every override bug:

// 1. The engine sees the preference
window.matchMedia('(prefers-reduced-motion: reduce)').matches; // → true

// 2. Nothing is still animating
document.getAnimations().filter(
  (a) => a.playState !== 'idle' && a.playState !== 'finished'
); // → []

A non-empty array from the second check almost always means a scroll-driven element whose animation-timeline was never reset to auto — the failure mode dissected in how to respect prefers-reduced-motion in CSS. Scroll the page and trigger a route change while emulation is on; scroll-linked animations only reveal themselves under scroll input.

Firefox has no Rendering-tab toggle for this feature. Open about:config, create the integer pref ui.prefersReducedMotion, and set it to 1 for reduce or 0 for no-preference. The pref applies browser-wide and overrides the OS; delete it to restore normal behavior. Safari offers no per-tab emulation at all — which leads directly to Step 2.

Step 2: Verify against the real OS toggles

Emulation substitutes a fake value at the evaluation stage; it cannot prove the engine actually reads your users’ operating systems. Once per release cycle — and always after adding a new class of animation — walk the real toggles:

Platform Toggle What to verify
macOS System Settings → Accessibility → Display → Reduce motion Safari, Chrome, and Firefox all flip live, without reload
Windows 11 Settings → Accessibility → Visual effects → Animation effects: off Chromium and Firefox both honor the setting
iOS / iPadOS Settings → Accessibility → Motion → Reduce Motion Safari and one WebKit-shell browser (Chrome or Firefox for iOS) both reduce
Android Settings → Accessibility → Remove animations Chrome for Android reports reduce; scroll-driven effects are gone

On each platform, load the page with the toggle already on (tests initial evaluation), then flip the toggle with the page open (tests live re-evaluation and your change listeners). The second pass matters for SPAs: a cached matchMedia snapshot that never updates is invisible in the first pass and obvious in the second — the subscription pattern that prevents it is covered in detecting prefers-reduced-motion in JavaScript with matchMedia.

Two platform notes. On iOS, every browser is WebKit, so one shell browser is a sufficient proxy for all of them. On Android, remember the toggle works by zeroing the system animator scale — a QA device with Developer Options animation scales set to off will report reduce permanently, which is correct behavior, not a bug.

Step 3: Mock matchMedia in unit tests

Unit tests run in jsdom or a similar environment where window.matchMedia either does not exist or always reports no-preference. Mock it so components that branch on the preference can be tested in both states, including the change-event path:

// test-utils/match-media-mock.js — Jest or Vitest
export function mockMatchMedia(initialReduced = false) {
  let matches = initialReduced;
  const listeners = new Set();

  const mql = {
    get matches() { return matches; },
    media: '(prefers-reduced-motion: reduce)',
    addEventListener: (_type, fn) => listeners.add(fn),
    removeEventListener: (_type, fn) => listeners.delete(fn),
  };

  window.matchMedia = (query) =>
    query.includes('prefers-reduced-motion') ? mql : { matches: false, media: query,
      addEventListener() {}, removeEventListener() {} };

  // Simulate the user flipping the OS toggle mid-session
  return function setReduced(next) {
    matches = next;
    listeners.forEach((fn) => fn({ matches: next, media: mql.media }));
  };
}
// Component test: asserts both the initial read and the live update
import { mockMatchMedia } from '../test-utils/match-media-mock';

test('carousel stops auto-advancing when motion preference flips', () => {
  const setReduced = mockMatchMedia(false);
  const carousel = mountCarousel();

  expect(carousel.isAutoPlaying).toBe(true);

  setReduced(true); // user enables Reduce Motion while the page is open
  expect(carousel.isAutoPlaying).toBe(false);
});

The returned setReduced function is the point of the mock: it lets tests exercise the mid-session flip that manual testers routinely forget. Keep the mock centralized in one utility so every suite simulates the preference the same way.

Step 4: Drive real browsers with Playwright’s reducedMotion option

Playwright emulates the preference at the browser-context level, before any page script runs, across Chromium, WebKit, and Firefox builds:

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

test.use({ reducedMotion: 'reduce' });

test('no animations run under reduced motion', async ({ page }) => {
  await page.goto('/');

  // Scroll to trigger any scroll-driven timelines that survived the override
  await page.mouse.wheel(0, 2000);

  const running = await page.evaluate(() =>
    document.getAnimations().filter(
      (a) => a.playState !== 'idle' && a.playState !== 'finished'
    ).length
  );
  expect(running).toBe(0);
});

test('route change announces instead of animating', async ({ page }) => {
  await page.goto('/');
  await page.click('a[href="/pricing/"]');
  await expect(page.locator('#transition-announcer')).toContainText('Navigated');
});

test.use({ reducedMotion: 'reduce' }) applies to every test in the file; page.emulateMedia({ reducedMotion: 'reduce' }) does the same per-page when you need to flip mid-test. Run the suite against all three engine projects in playwright.config.js so WebKit and Firefox behavior is asserted, not assumed — the closest automated approximation of the per-engine matrix. Note the scroll gesture in the first test: a scroll-driven animation with an intact animation-timeline reports idle until scrolling starts, so an assertion without scrolling passes vacuously.

Pair every reduce assertion with its inverse under no-preference — motion that silently disappeared for everyone is the regression these tests exist to catch on the other side.

Step 5: Gate the suite in CI

Structure the config so both preference states run as first-class projects on every pull request:

// playwright.config.js
export default {
  projects: [
    { name: 'chromium-motion', use: { browserName: 'chromium', reducedMotion: 'no-preference' } },
    { name: 'chromium-reduced', use: { browserName: 'chromium', reducedMotion: 'reduce' } },
    { name: 'webkit-reduced', use: { browserName: 'webkit', reducedMotion: 'reduce' } },
    { name: 'firefox-reduced', use: { browserName: 'firefox', reducedMotion: 'reduce' } },
  ],
};

Make the reduced-motion projects blocking, not advisory. A failing zero-animations assertion should stop the merge exactly like a failing type check, because it usually means a newly added animation shipped without a guard — the class of omission that code review misses most often. Since the animations under test are progressive enhancements, follow the discipline in browser support and progressive enhancement: the Firefox and WebKit projects primarily verify that baseline content is visible and static, while the Chromium projects additionally verify that scroll timelines detach under reduce.

Verification

The release-readiness checklist, condensed from all four layers:

  1. Chromium Rendering-tab emulation on: matchMedia('(prefers-reduced-motion: reduce)').matches is true, and document.getAnimations() shows nothing running after a full-page scroll and one route change.
  2. Firefox with ui.prefersReducedMotion = 1: same two checks.
  3. macOS Reduce Motion flipped while Safari has the page open: animations stop without a reload.
  4. One real iOS device and one real Android device pass the Step 2 table checks.
  5. Unit suite covers both mock states plus the mid-session flip.
  6. Playwright projects for reduce pass on Chromium, WebKit, and Firefox; the no-preference project confirms motion still exists for users who want it.
  7. CI marks all reduced-motion projects as required checks.
  8. The aria-live announcement path for skipped View Transitions is asserted in at least one end-to-end test.

Edge cases and gotchas

Emulation state leaking between manual checks. The Chromium Rendering-tab override silently resets when DevTools closes. If animations “came back” between two checks, confirm the emulation is still active before filing a bug against your own CSS.

getAnimations() misses future animations. The call returns currently known animations. A scroll-driven animation outside every animation-range can be absent from the list, then start on scroll. Always scroll before asserting, and prefer asserting after interaction, not just after load.

jsdom cannot test the cascade. The matchMedia mock validates JavaScript branching only; it proves nothing about whether your @media blocks apply. CSS-level behavior needs the Playwright layer — do not let a green unit suite stand in for it.

Playwright’s emulation bypasses the OS link. Like DevTools, reducedMotion: 'reduce' injects the value at the engine. It will never catch an OS-integration regression — that is what the Step 2 device pass is for, and why the automated layers cannot fully retire it.

Screenshot tests fighting motion. If visual regression snapshots flake on animated pages, run them under the reduce project. Determinism improves, and every screenshot doubles as a free assertion that the reduced state renders correctly.

Mid-test preference flips need page.emulateMedia. test.use fixes the value for the whole test. To simulate a user toggling the OS setting mid-session in an end-to-end test, call await page.emulateMedia({ reducedMotion: 'reduce' }) partway through and assert the page reacts — this exercises the same change-event path as the unit-level setReduced helper.

Browser-specific notes

Chrome / Edge (Chromium). The most complete tooling: Rendering-tab emulation, change events on emulation flips, and headless behavior identical to headed for this feature. Scroll timelines have shipped since Chrome/Edge 115, so Chromium is where the animation-timeline: auto detachment must actually be proven.

Safari / WebKit. No per-tab emulation; use the macOS toggle for manual work and Playwright WebKit for automation. Safari has honored prefers-reduced-motion since 10.1, and on iOS every browser inherits WebKit’s evaluation. Scroll-driven animation support in Safari is in active development, so today’s WebKit checks mostly verify the static baseline — re-run the full matrix when support lands.

Firefox. prefers-reduced-motion has been supported since Firefox 63, with the ui.prefersReducedMotion pref as the manual override. animation-timeline remains behind a pref in stable builds, so as with WebKit, Firefox checks validate the baseline and the change-event path rather than timeline detachment.

All engines. The change event fires on real OS flips, DevTools emulation flips, and page.emulateMedia calls alike — so a listener verified in any one layer is exercised consistently across all of them. What differs per engine is only how the value gets injected.


Up: Motion Preference Support Matrix