Testing prefers-reduced-motion Without Brittle Timing Assertions
By Antoine Dubois · August 31, 2026
Practical patterns for testing prefers-reduced-motion in browser automation, including animation cancellation checks, motion-safe UI state assertions, and avoiding brittle timing tests.
Motion bugs are easy to miss because the UI can look correct while the browser is still animating toward a final state. That matters more when the user has asked for reduced motion, because the right behavior is not just “shorter animation”, it is often “no meaningful motion at all”, with the UI ending in a stable, readable state immediately.
The goal of reduced motion accessibility testing is not to prove that every frame renders exactly as expected. It is to verify three things:
- The browser reports
prefers-reduced-motion: reduceand the app responds to it. - Animations are disabled or canceled cleanly, not merely sped up.
- The UI does not get stuck in an intermediate state when motion is removed.
That third point is where brittle timing assertions usually fail.
The core distinction: reduced motion is not the same as “wait less”
A common mistake is to keep the animation and just shorten its duration in tests. That can hide two classes of defects:
- the component still depends on the animation finishing to apply its final styles,
- the component cancels the animation but forgets to apply the end state synchronously.
When you test prefers-reduced-motion, you are really testing a state machine. The animation is only one transition path. The assertions should focus on final state, accessibility-relevant visibility, and cancellation behavior, not on frame counts or arbitrary sleeps.
If your test needs
waitForTimeout(500)to know a drawer is open, the test is asserting on animation speed, not user-visible state.
What the browser signal means
The CSS media feature prefers-reduced-motion is standardized in CSS Media Queries. A user agent can expose reduce when the user has enabled a system preference for less motion. In browser automation, you usually emulate that preference at the browser context or page level, then verify the app’s CSS and JavaScript react accordingly.
For the browser behavior itself, the relevant standards and references are:
- CSS Media Queries Level 5, for
prefers-reduced-motion - WCAG guidance on motion and animation, especially success criteria around animations from interactions
The practical consequence is simple: a test should not depend on a visual transition existing at all.
A decision framework for motion-safe UI tests
Use different checks depending on what you need to prove.
| What you are testing | Best assertion style | What to avoid |
|---|---|---|
| CSS honors reduced motion | Check computed styles or media query behavior | Waiting for animation timeouts |
| JavaScript cancels animation work | Verify no active animation remains, or state is committed | Checking a pixel at a fixed timestamp |
| Interactive components end in a usable state | Assert final DOM, visibility, focus, and aria state | Asserting intermediate animation frames |
| Visual regressions in motion-heavy UI | Capture steady-state screenshots with motion disabled | Recording animated screenshots as a primary check |
If the component is mostly CSS-driven, computed style and final state checks are usually enough. If it is JS-driven, add cancellation checks so the test fails when timers, promises, or animation callbacks leave the component half-open.
A reliable pattern: force reduced motion, then assert final state
In Playwright and similar browser automation tools, you can emulate reduced motion through browser context options. The exact API varies, but the test shape should stay the same: set the media feature, perform the user action, then assert the end state.
import { test, expect } from '@playwright/test';
test('modal opens without motion when reduced motion is enabled', async ({ browser }) => {
const context = await browser.newContext({
reducedMotion: 'reduce'
});
const page = await context.newPage();
await page.goto('http://localhost:3000');
await page.getByRole('button', { name: 'Open modal' }).click();
const modal = page.getByRole('dialog');
await expect(modal).toBeVisible();
await expect(modal).toHaveAttribute('aria-hidden', 'false');
});
This test does not care whether the dialog fades, slides, or snaps into place. It cares that the dialog becomes visible and accessible immediately under reduced motion.
Add a direct media-query assertion when the CSS matters
If the behavior is CSS-driven, verify the effective media query as well. That catches cases where the component logic is correct but the stylesheet still contains motion-dependent rules.
const motion = await page.evaluate(() =>
matchMedia('(prefers-reduced-motion: reduce)').matches
);
expect(motion).toBe(true);
This is not a substitute for UI assertions. It is a cheap guard that confirms the browser context is configured the way the test expects.
How to test animation cancellation, not just the absence of motion
Disabling motion is only half the job. The component should also cancel any in-flight animation cleanly when reduced motion is active, or when motion changes during runtime.
There are two common implementation styles:
- CSS transitions and animations, often controlled by a
prefers-reduced-motionmedia query - JavaScript animation APIs such as
requestAnimationFrame, Web Animations API, or library-specific motion systems
For CSS animations, you want to know whether the element still has an active transition or animation after the action completes. For JS animations, you want to know whether the cancel path leaves the DOM in a committed final state.
CSS animation cancellation test
const panel = page.locator('[data-testid="sidebar"]');
await page.getByRole('button', { name: 'Toggle sidebar' }).click();
await expect(panel).toHaveClass(/is-open/);
const state = await panel.evaluate((el) => {
const styles = getComputedStyle(el);
return {
transitionDuration: styles.transitionDuration,
animationName: styles.animationName
};
});
expect(state.transitionDuration).toBe(‘0s’); expect(state.animationName).toBe(‘none’);
This works when your contract is “reduced motion means no CSS animation should be active.” Adjust the check to match your own CSS architecture. If your design system still allows a tiny opacity transition, document that choice explicitly and test for it directly.
JavaScript animation cancellation test
If the component uses a manual animation loop, test the end state after cancellation rather than timing the animation itself.
await page.getByRole('button', { name: 'Expand details' }).click();
await expect(page.getByTestId('details')).toHaveAttribute('data-state', 'open');
await expect(page.getByTestId('details-content')).toBeVisible();
The implementation should cancel any pending animation work when prefers-reduced-motion is active, but the test should only care that the UI state is correct and stable after the interaction.
Test the dangerous edge case: preference changes mid-session
Users can change the reduced-motion preference while a page is open. That is not a theoretical edge case, it is a state transition your app may need to handle.
A robust component should not assume the preference is only read once during startup. If the implementation listens for media query changes, verify that the UI settles cleanly after the preference flips.
await page.addInitScript(() => {
const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
Object.defineProperty(window, 'matchMedia', {
value: (query: string) => ({
matches: query === '(prefers-reduced-motion: reduce)',
media: query,
onchange: null,
addEventListener: () => {},
removeEventListener: () => {},
addListener: () => {},
removeListener: () => {},
dispatchEvent: () => false
})
});
});
In real suites, you will usually prefer your framework’s built-in emulation over a custom stub. The point here is the failure mode: if the app caches the initial preference forever, it may keep animating after the user changes the setting.
What to assert instead of timing
Use assertions that reflect user-visible correctness.
Good assertions
- element is visible or hidden
aria-expanded,aria-hidden, and related accessibility attributes have the final value- focus moved to the right element after open or close
- content is present in the DOM when it should be reachable
- scrolling, overlay locking, or body classes are committed
- no active animation is left running when reduced motion is enabled
Weak assertions
- “wait 300 ms and then check”
- screenshot taken at an arbitrary moment during the transition
- checking one pixel position while the animation is still moving
- validating only that the CSS class for animation exists or disappears
A transition can be technically present and still harmless. A motion-safe UI can also be wrong even if the transition completed. Final-state assertions catch the user-facing bug, which is what matters.
A simple contract for component teams
If you maintain a design system, define a motion contract that engineering and QA can share. For example:
- under
prefers-reduced-motion: reduce, decorative motion is removed, - state-changing interactions still occur, but without motion-dependent intermediate states,
- focus order, keyboard behavior, and ARIA state are unchanged,
- animations that drive layout changes must commit their final layout synchronously.
That contract makes tests easier to write because each component has a clear expectation. It also reduces disagreement during review, because the failure mode is defined up front.
The best motion test is often a state test with one extra assertion, not a dedicated animation test.
Where Cypress, Selenium, and Playwright differ
The browser-automation strategy is the same across tools, but the ergonomics differ.
- Playwright gives you straightforward browser-context emulation for reduced motion and strong locators for final-state assertions.
- Cypress can also test the result well, but teams sometimes need more care around browser-level emulation and test isolation.
- Selenium can validate the same contract, but you may write more plumbing to control browser preferences and inspect computed styles reliably.
The tool matters less than the structure of the test. If the suite leans on timing, it will be flaky in any framework.
A debugging checklist when the test fails
When a reduced-motion test breaks, inspect these layers in order:
- Browser emulation: is
matchMedia('(prefers-reduced-motion: reduce)')returning the expected value? - CSS branch: does the stylesheet actually remove the relevant transition or animation?
- JS branch: does the interaction handler cancel timers, RAF loops, or animation promises?
- State commit: does the final DOM state exist even when animation is bypassed?
- A11y state: are
aria-*, focus, and inert/overlay behaviors aligned with the visual state?
This order prevents a common trap, chasing a visual symptom when the actual bug is a stale state update.
Not the best fit if…
This pattern is not ideal when you only need a very coarse smoke check for a marketing site banner or a lightweight content animation. In that case, one targeted accessibility test plus a separate visual regression baseline may be enough.
It is also not enough for physics-heavy canvas motion, game-like interactions, or highly custom motion systems where the browser does not expose a useful DOM contract. Those cases often need lower-level instrumentation or component-specific assertions.
A practical default recommendation
If your application has meaningful motion, I would start with this rule set:
- emulate
prefers-reduced-motion: reducein browser automation, - assert the final UI state, not animation duration,
- add one cancellation check for each interactive component that animates state,
- reserve screenshots for steady states, not in-flight transitions.
That gives you coverage of the actual user experience without making the suite depend on frame timing. It is cheaper to maintain, easier to debug, and closer to the accessibility requirement.
FAQ
Should I disable all animations in reduced motion tests?
Usually yes for decorative motion, but not always for essential state changes. The important part is that the UI does not rely on motion to become usable or understandable.
Is checking matchMedia enough to prove accessibility?
No. It only proves the browser reports the preference. You still need to verify the component behavior and final accessibility state.
What if my animation is part of layout, not decoration?
Assert that the final layout is committed immediately when reduced motion is active. Do not rely on the transition to finish before the component becomes usable.
How do I keep these tests from becoming flaky?
Avoid arbitrary sleeps, assert final DOM and accessibility state, and inspect computed styles only when they directly support the behavior under test.
Do I need visual regression tests for motion-safe UI?
They help, but use them for stable screenshots. Motion-safe behavior is better covered by state and cancellation assertions than by animated image diffs.