Automating Reduced-Motion Checks in CI
Part of Motion Testing & Automation in Accessible Motion Architecture.
The problem
A reduced-motion gate is correct on the day it is written and quietly wrong three sprints later. Someone adds a second animation to a component and puts it outside the media query; someone converts a CSS transition to element.animate() and the cascade’s gate stops applying; someone ships a new page that copies an old component and forgets the override.
None of that shows up in review, because the diff looks reasonable and the page looks fine to anyone who does not have the preference set. It only shows up for the users the gate exists to protect, who have no way to report it beyond “the site makes me feel unwell”.
Root cause: the reduced path is never exercised
Every other rendering path gets exercised constantly — by developers, by reviewers, by anyone using the product. The reduced-motion path is rendered only for the subset of users who set the preference, and only they can see it.
Automation closes that gap cheaply, because motion is unusually well suited to assertion. The preference is an emulatable media feature, so both branches are reachable in one process. Running animations are enumerable through document.getAnimations(), which returns CSS transitions, CSS animations and scripted effects together, with their timing and their targets. And both branches are deterministic, so the two end states can be compared exactly.
What makes such suites fail in practice is not the assertions — it is the waiting. A test that sleeps 500 milliseconds and then checks is flaky on a loaded CI runner. A test that awaits every animation’s finished promise is not.
Step-by-step resolution
Production code pattern
// motion.spec.js — a complete, non-flaky reduced-motion suite.
import { test, expect } from '@playwright/test';
const ROUTES = ['/', '/components/modal/', '/components/feed/'];
/** Wait for the platform, never for the clock. */
async function settle(page) {
await page.evaluate(() =>
Promise.allSettled(document.getAnimations().map((a) => a.finished))
);
}
/** Everything currently animating, with the facts an assertion needs. */
async function runningEffects(page) {
return page.evaluate(() => {
const viewport = Math.max(window.innerWidth, window.innerHeight);
return document.getAnimations().map((animation) => {
const timing = animation.effect?.getTiming?.() ?? {};
const frames = animation.effect?.getKeyframes?.() ?? [];
const travel = frames.reduce((max, frame) => {
const source = `${frame.transform ?? ''} ${frame.translate ?? ''}`;
const px = [...source.matchAll(/(-?[\d.]+)px/g)]
.map((m) => Math.abs(Number(m[1])));
return Math.max(max, ...px, 0);
}, 0);
return {
target: animation.effect?.target?.className ?? '(unknown)',
infinite: timing.iterations === Infinity,
travelRatio: travel / viewport,
duration: timing.duration,
};
});
});
}
for (const route of ROUTES) {
test(`${route} animates at all without the preference`, async ({ page }) => {
await page.emulateMedia({ reducedMotion: 'no-preference' });
await page.goto(route);
await page.getByTestId('primary-interaction').click();
// Guard against a vacuous pass in the test below.
expect((await runningEffects(page)).length).toBeGreaterThan(0);
});
test(`${route} removes travel and loops under reduce`, async ({ page }) => {
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.goto(route);
await page.getByTestId('primary-interaction').click();
const offenders = (await runningEffects(page)).filter(
(effect) => effect.infinite || effect.travelRatio > 0.1
);
expect(offenders, JSON.stringify(offenders, null, 2)).toEqual([]);
});
}
test('both branches settle identically', async ({ page }) => {
const read = async (scheme) => {
await page.emulateMedia({ reducedMotion: scheme });
await page.goto('/components/modal/');
await page.getByTestId('primary-interaction').click();
await settle(page);
return page.$eval('[data-testid="panel"]', (el) => {
const s = getComputedStyle(el);
const r = el.getBoundingClientRect();
return { opacity: s.opacity, display: s.display,
top: Math.round(r.top), left: Math.round(r.left) };
});
};
expect(await read('reduce')).toEqual(await read('no-preference'));
});
Rendering Impact: none in production — this code runs in CI. The assertions read the same effect timing and keyframe data the reduced-motion cascade produces, so a failure points at a stylesheet declaration rather than at a pixel difference.
The travelRatio threshold of a tenth of the viewport is doing real work: it encodes the vestibular guidance as an executable rule. It is deliberately crude — it reads pixel values out of keyframes rather than measuring rendered movement — and crude is fine, because the failures it catches are not subtle.
Verification checklist
Constraints and trade-offs
- Reading pixel values out of keyframes misses travel expressed in percentages or viewport units; extend the parser as your codebase needs it.
- The suite proves gates exist, not that the reduced experience is good — that remains a review question.
- Emulation applies from navigation, so a mid-session preference change needs its own separate test.
- Excluding third-party embeds is necessary and should be visible in the test file, not buried in configuration.
- Every route added is a navigation on every CI run; keep the list representative rather than exhaustive.
Frequently asked questions
How many routes should the suite cover?
Start with three or four representative ones: the busiest page, a page with a modal, and a long list. Motion assertions generalise across components, so route coverage matters more than component coverage — one route with ten animated components exercises all ten.
Will these tests be flaky?
Only if they sleep. Every wait should be on a promise the platform provides: navigation completion, an animation’s finished promise, or an element becoming visible. A suite built that way is as deterministic as a unit test.
What do I do about third-party embeds that animate?
Scope the assertion to your own DOM by excluding known third-party containers, and record the exclusion in the test. Silently passing because an iframe was skipped is fine; silently passing because you never looked is not.
Keeping the suite fast enough to run on every commit
A motion suite earns its place only if nobody is tempted to skip it, which in practice means it has to finish in well under a minute.
Three decisions keep it there. Share one browser context across the assertions for a route rather than navigating fresh for each: the emulation setting can be changed on an existing page, so one navigation can serve both branches. Assert in one page.evaluate() call rather than several — each round trip to the browser is a few milliseconds, and a loop of them across twenty elements adds up. And keep the route list representative rather than exhaustive; motion regressions are systemic, so a component that appears on three pages needs testing on one of them.
What not to economise on is the positive assertion. Skipping the “does it animate at all without the preference” check saves one interaction and removes the property that makes the whole suite meaningful — a reduced-motion assertion over an empty array passes forever, including after someone accidentally deletes every animation on the page.
If the suite does grow slow, the usual culprit is waiting rather than working: a stray waitForTimeout, or an await settle() on a page with an infinite ambient animation whose finished promise never resolves. Filter infinite effects out of the settle helper and the timing problem usually disappears on its own.
Related
- Motion Testing & Automation — the parent guide on what is worth asserting
- prefers-reduced-motion Architecture — the cascade the assertions are checking
- Vestibular-Safe Motion Patterns — where the travel threshold comes from