How to test prefers reduced motion

Emulate the preference with Emulation.setEmulatedMedia and a prefers-reduced-motion feature of reduce, reload, then read what changed. On the dashboard below the CSS transition fell to 0s and the CSS animation left document.getAnimations(), while a scripted animation kept running and the page produced 62 frames in 500 ms.

Why check this

Run this whenever a release adds a carousel, a marquee, a parallax header or a loading shimmer. The preference is set by people who get motion sickness or migraines from screen movement, so a page that ignores it is unusable rather than untidy.

The defect it catches is a partial fix. A stylesheet grows a @media (prefers-reduced-motion: reduce) block, the transitions stop, the ticket closes, and the carousel keeps sliding because it is driven from JavaScript. The media query does not reach an element.animate() call or a requestAnimationFrame loop, and nothing in the CSS reports that.

What the query covers

prefers-reduced-motion is a CSS media feature. It changes which CSS rules apply, and that is all it does on its own. Script reads it only when someone writes matchMedia('(prefers-reduced-motion: reduce)') into the code. Step 2 counts how many scripts on the page did.

Prerequisites

// server.mjs
import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
const port = Number(process.argv[2] || 8947);
const types = { '.html': 'text/html; charset=utf-8', '.css': 'text/css', '.js': 'text/javascript' };
createServer(async (req, res) => {
  const path = req.url.split('?')[0];
  const ext = path.slice(path.lastIndexOf('.'));
  try {
    const body = await readFile(new URL('.' + path, import.meta.url));
    res.writeHead(200, { 'content-type': types[ext] ?? 'text/plain' });
    res.end(body);
  } catch {
    res.writeHead(404, { 'content-type': 'text/plain' });
    res.end('not found');
  }
}).listen(port, () => console.log('serving on http://127.0.0.1:' + port + '/'));
<!-- motion.html -->
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Dashboard</title>
<style>
  body { font: 16px/1.5 system-ui, sans-serif; margin: 0; padding: 16px; }
  #card  { width: 200px; height: 80px; background: #ddd; transition: transform 600ms ease; }
  @keyframes pulse { from { opacity: .3 } to { opacity: 1 } }
  #badge { display: inline-block; padding: 4px 8px; background: #fc3;
           animation: pulse 1200ms infinite alternate; }
  #track { position: relative; height: 40px; width: 320px; background: #f4f4f4; }
  #slider { position: absolute; top: 8px; width: 24px; height: 24px; background: #38a; }
  #dot    { position: absolute; top: 8px; left: 0; width: 12px; height: 12px; background: #a33;
            border-radius: 50%; }
  @media (prefers-reduced-motion: reduce) {
    #card  { transition-duration: 0ms; }
    #badge { animation: none; }
  }
</style>
</head>
<body>
<h1>Dashboard</h1>
<div id="card"></div>
<p><span id="badge">Live</span></p>
<div id="track"><div id="slider"></div><div id="dot"></div></div>
<script>
  // Web Animations API. No media query anywhere near it.
  document.getElementById('slider').animate(
    [{ transform: 'translateX(0px)' }, { transform: 'translateX(280px)' }],
    { duration: 2000, iterations: Infinity, direction: 'alternate', id: 'slider-loop' }
  );
  // requestAnimationFrame ticker. Also unguarded.
  let frames = 0, x = 0;
  const dot = document.getElementById('dot');
  (function tick() {
    frames += 1; x = (x + 3) % 300;
    dot.style.left = x + 'px';
    window.__frames = frames;
    requestAnimationFrame(tick);
  })();
</script>
</body>
</html>
// motion.mjs
import { launch } from 'puppeteer-core';
const CHROME = process.env.CHROME ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe';
const url = process.argv[2];
const browser = await launch({ executablePath: CHROME, headless: true });
const page = await browser.newPage();
const cdp = await page.createCDPSession();

async function capture(label) {
  await page.reload({ waitUntil: 'networkidle2' });
  const before = await page.evaluate(() => ({
    matches: matchMedia('(prefers-reduced-motion: reduce)').matches,
    transition: getComputedStyle(document.getElementById('card')).transitionDuration,
    animation: getComputedStyle(document.getElementById('badge')).animationName,
    running: document.getAnimations().map((a) =>
      `${a.constructor.name} ${a.animationName ?? a.id ?? '(anonymous)'} ${a.playState} ` +
      `${a.effect.getTiming().duration}ms`),
    frames: window.__frames ?? 0,
    dot: document.getElementById('dot').style.left,
  }));
  await new Promise((r) => setTimeout(r, 500));
  const after = await page.evaluate(() => ({
    frames: window.__frames ?? 0,
    dot: document.getElementById('dot').style.left,
  }));
  console.log(`--- ${label}`);
  console.log(`prefers-reduced-motion: reduce matches   ${before.matches}`);
  console.log(`#card  computed transition-duration      ${before.transition}`);
  console.log(`#badge computed animation-name           ${before.animation}`);
  console.log(`document.getAnimations()                 ${before.running.length} entr${before.running.length === 1 ? 'y' : 'ies'}`);
  for (const r of before.running) console.log('  ' + r);
  console.log(`rAF frames in 500 ms                     ${after.frames - before.frames}`);
  console.log(`#dot left, start then +500 ms            ${before.dot} then ${after.dot}`);
}

await page.goto(url, { waitUntil: 'networkidle2' });
await capture('no preference (Chrome default)');

await cdp.send('Emulation.setEmulatedMedia', {
  features: [{ name: 'prefers-reduced-motion', value: 'reduce' }],
});
await capture('prefers-reduced-motion: reduce, emulated');

await browser.close();
// still-moving.mjs
import { launch } from 'puppeteer-core';
const CHROME = process.env.CHROME ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe';
const browser = await launch({ executablePath: CHROME, headless: true });
const page = await browser.newPage();
const cdp = await page.createCDPSession();
await cdp.send('Emulation.setEmulatedMedia', {
  features: [{ name: 'prefers-reduced-motion', value: 'reduce' }],
});
await page.goto(process.argv[2], { waitUntil: 'networkidle2' });
const first = await page.evaluate(() => {
  let blocks = 0, readable = 0;
  const sheets = [...document.styleSheets];
  for (const sheet of sheets) {
    try {
      for (const rule of sheet.cssRules) {
        if (rule.conditionText?.includes('prefers-reduced-motion')) blocks += 1;
      }
      readable += 1;
    } catch { /* cross-origin stylesheet, rules not readable */ }
  }
  const scripts = [...document.querySelectorAll('script:not([src])')].map((s) => s.textContent);
  const moving = document.getAnimations().map((a) => {
    const t = a.effect?.target;
    const el = t ? `${t.tagName.toLowerCase()}${t.id ? '#' + t.id : ''}` : '(none)';
    return `  ${el.padEnd(14)} ${a.constructor.name.padEnd(14)} ${a.animationName ?? a.id ?? '(anonymous)'} ${a.playState}`;
  });
  return {
    sheets: sheets.length, readable, blocks, scripts: scripts.length,
    js: scripts.filter((t) => t.includes('prefers-reduced-motion')).length,
    moving, frames: window.__frames ?? null,
  };
});
await new Promise((r) => setTimeout(r, 500));
const frames = await page.evaluate(() => window.__frames ?? null);
console.log(`stylesheets ${first.readable} of ${first.sheets} readable   @media (prefers-reduced-motion) blocks: ${first.blocks}`);
console.log(`inline scripts ${first.scripts}   referencing prefers-reduced-motion: ${first.js}`);
console.log(`still animating under reduce: ${first.moving.length}`);
for (const m of first.moving) console.log(m);
if (frames !== null) console.log(`rAF frames in the next 500 ms: ${frames - first.frames}`);
await browser.close();

Steps

  1. Step 1.

    Capture the page twice, once at the default and once with the preference emulated.

    node motion.mjs http://127.0.0.1:8947/motion.html
    
    --- no preference (Chrome default)
    prefers-reduced-motion: reduce matches   false
    #card  computed transition-duration      0.6s
    #badge computed animation-name           pulse
    document.getAnimations()                 2 entries
    CSSAnimation pulse running 1200ms
    Animation slider-loop running 2000ms
    rAF frames in 500 ms                     60
    #dot left, start then +500 ms            213px then 93px
    --- prefers-reduced-motion: reduce, emulated
    prefers-reduced-motion: reduce matches   true
    #card  computed transition-duration      0s
    #badge computed animation-name           none
    document.getAnimations()                 1 entry
    Animation slider-loop running 2000ms
    rAF frames in 500 ms                     62
    #dot left, start then +500 ms            201px then 87px

    The CSS half worked. transition-duration went from 0.6s to 0s, animation-name from pulse to none, and the CSSAnimation entry left document.getAnimations(). The other half did not move. slider-loop is still running at 2000ms, and the ticker produced 62 frames in half a second against 60 before, so the dot travelled the same distance. Its left wraps at 300 px, which is why 201 px becomes 87 px rather than 387 px.

  2. Step 2.

    Name what is still moving and find out whether any script ever asked about the preference.

    node still-moving.mjs http://127.0.0.1:8947/motion.html
    
    stylesheets 1 of 1 readable   @media (prefers-reduced-motion) blocks: 1
    inline scripts 1   referencing prefers-reduced-motion: 0
    still animating under reduce: 1
    div#slider     Animation      slider-loop running
    rAF frames in the next 500 ms: 60

    Three numbers make the bug report. One media block in the CSS, zero references in script, and one animation still running on a named element. The fix is a matchMedia check around the animate() call and the requestAnimationFrame loop, plus a listener on that media query so a change mid-session takes effect.

  3. Step 3.

    Run the same reading on a page built by a team that did take the preference seriously.

    node still-moving.mjs https://www.cloudflare.com/
    
    stylesheets 20 of 20 readable   @media (prefers-reduced-motion) blocks: 9
    inline scripts 40   referencing prefers-reduced-motion: 1
    still animating under reduce: 14
    canvas         CSSAnimation   canvas-fade-in finished
    canvas         CSSAnimation   canvas-fade-in finished
    span           CSSAnimation   rotating-text-noise-drift running
    div            CSSAnimation   canvas-fade-in finished
    div            CSSAnimation   waveform-scroll running
    div            CSSAnimation   canvas-fade-in finished
    div            CSSAnimation   marquee running
    span           CSSAnimation   rotating-text-noise-drift running
    div            CSSAnimation   infinite-scroll running
    div            CSSAnimation   infinite-scroll running
    div            CSSAnimation   infinite-scroll running
    div            CSSAnimation   infinite-scroll running
    div            CSSAnimation   infinite-scroll running
    div            CSSAnimation   infinite-scroll running

    Nine media blocks and a script reference, and fourteen animations survive. Read the playState column before the count: four of the fourteen are finished, so they are not motion. The ten marked running include names like marquee and infinite-scroll, which are the ones to open in the Elements panel. Coverage is per component, and a count of media blocks does not measure it.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | matches false with the emulation on | The emulation did not apply | Check the feature name and value in the setEmulatedMedia call, then reload | | transition-duration unchanged under reduce | No CSS rule covers that element | Add the element to the reduce block, or set the duration on a shared selector | | A CSSAnimation entry that survives reduce | A keyframe animation is outside the reduce block | Find it by animationName in the stylesheet | | An Animation entry that survives reduce | A script called element.animate() | Guard the call with matchMedia, and add a change listener | | rAF frames still counting under reduce | A requestAnimationFrame loop is running | Stop scheduling, or hold the element still, when the query matches | | playState finished | The animation ran once and stopped | Not a defect. Filter the list by running before counting |

Common mistakes

Sign: A reduce block is added to the stylesheet and the ticket is closed.Cause: The media feature only decides which CSS rules apply. On the capture above the transition and the keyframe animation both stopped while a slider driven by element.animate() kept running at 2000ms and the rAF loop produced 62 frames in 500 ms under reduce, against 60 before. Nothing in the CSS reports the gap.
Sign: The count of surviving animations is used as the score.Cause: Cloudflare's home page returned fourteen entries under an emulated reduce, and four of them had playState finished, so they were not moving. Reading the count without the state overstates the defect by a third and buries the ten that are actually running.
Sign: Computed styles are trusted as the whole answer.Cause: Under reduce the badge reported animation-name none, which looks like proof that nothing animates. document.getAnimations() still listed a running Animation on a different element in the same capture. Computed style answers per property on the element you asked about; getAnimations answers for the document.
Sign: The preference is set once at load and never changed again.Cause: A user can turn the setting on while the page is open. A matchMedia check that runs once at startup leaves the animation running for the rest of the session. Emulate the preference after load, without a reload, to see whether the page reacts.

What to check next

FAQ

How do I test prefers reduced motion in Chrome?

Open DevTools, press Ctrl+Shift+P, run "Show Rendering", and set "Emulate CSS media feature prefers-reduced-motion" to reduce. That control sends the Emulation.setEmulatedMedia command the scripts above send, so the DevTools path and the scripted path give the same result.

How do I change the prefers-reduced-motion setting in browsers?

Browsers read it from the operating system: Settings, Accessibility, Visual effects, Animation effects on Windows 11, and System Settings, Accessibility, Display, Reduce motion on macOS. Firefox also exposes ui.prefersReducedMotion in about:config. Emulation is faster for a test run and leaves the machine alone.

Does prefers-reduced-motion stop a JavaScript animation?

No. It is a CSS media feature, so it changes which CSS rules apply. An element.animate() call or a requestAnimationFrame loop keeps running until the code itself checks matchMedia('(prefers-reduced-motion: reduce)'). Step 2 counts how many scripts on the page do.

Is ignoring the preference a WCAG failure?

Not by itself. SC 2.2.2 Pause, Stop, Hide, level A, requires a way to pause, stop or hide content that moves automatically for more than five seconds beside other content. SC 2.3.3 Animation from Interactions, level AAA, covers motion triggered by interaction. Honouring the preference is how most teams satisfy both.

Verified

Verified by Maks VernyChrome 152.0.7977.76Node 22.23.2puppeteer-core 25.10.0

Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.

intermediate8 minpublished updated Maks Verny