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
- Chrome 120 or later and Node 22.
npm i puppeteer-coreinstalls the driver only and uses the Chrome already on the machine. SetCHROMEif yours is elsewhere. - The prefers-reduced-motion reference on MDN lists the two values and the operating system settings that produce them.
- The DevTools Rendering panel has an "Emulate CSS media feature prefers-reduced-motion" control. It sends the same
Emulation.setEmulatedMediacommand the scripts below send. - A page that honours the preference in CSS and ignores it everywhere else. Save both files in one directory, run
node server.mjs 8947, and stop it by PID afterwards, never by image name.
// 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>
- Two scripts, same directory.
// 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();
- Every figure below is one capture on one machine, Chrome 152.0.7977.76, 2026-09-11. Frame counts move with machine load.
Steps
- 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 87pxThe CSS half worked.
transition-durationwent from 0.6s to 0s,animation-namefrompulsetonone, and theCSSAnimationentry leftdocument.getAnimations(). The other half did not move.slider-loopis still running at 2000ms, and the ticker produced 62 frames in half a second against 60 before, so the dot travelled the same distance. Itsleftwraps at 300 px, which is why 201 px becomes 87 px rather than 387 px. - 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.htmlstylesheets 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: 60Three 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
matchMediacheck around theanimate()call and therequestAnimationFrameloop, plus a listener on that media query so a change mid-session takes effect. - 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 runningNine media blocks and a script reference, and fourteen animations survive. Read the
playStatecolumn before the count: four of the fourteen arefinished, so they are not motion. The ten markedrunninginclude names likemarqueeandinfinite-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
What to check next
- How to test a page at 200 percent zoom: the same emulation approach, applied to viewport width.
- How to find css breakpoints of a website: the other media queries a stylesheet is carrying.
- How to check CLS: an unguarded animation that moves layout scores there too.
- How to check tap target size: a target that drifts under the finger is a separate defect.
- How to run an axe accessibility test: the rule engine pass over the same page.
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.
Related on this site
intermediate8 minpublished updated Maks Verny