Testing Reduced Motion Preferences
Permalink to "Testing Reduced Motion Preferences"Data interfaces animate more than people expect: bars grow from zero when charts load, numbers count up on KPI cards, lines draw themselves, skeletons shimmer, live feeds scroll, rows slide when sorted, and pages scroll smoothly to anchors. For users with vestibular disorders, motion can cause dizziness and nausea; for users with attention-related disabilities, it is distracting. Operating systems offer a “reduce motion” setting, exposed to the web as prefers-reduced-motion. Testing that the UI respects it is straightforward with media emulation.
This guide belongs to visual regression & theme testing.
Spec reference
Permalink to "Spec reference"- SC 2.3.3 Animation from Interactions (AAA) — motion triggered by interaction can be disabled unless essential. AAA, but
prefers-reduced-motionis the standard way to meet it. - SC 2.2.2 Pause, Stop, Hide (A) — moving, blinking or auto-updating content that starts automatically, lasts more than five seconds and appears with other content needs a pause, stop or hide mechanism — regardless of user preferences.
- SC 2.3.1 Three Flashes or Below Threshold (A) — no flashing more than three times per second.
- CSS
prefers-reduced-motion: reduce— the user’s OS setting; Playwright emulates it withreducedMotion: 'reduce'.
When to use — and when not to
Permalink to "When to use — and when not to"Test reduced motion for every component with transitions or animation, and for any page with auto-updating content. It is quick to automate and catches regressions when new animation libraries arrive.
Do not interpret reduced motion as “remove all transitions everywhere”. Subtle opacity fades are generally fine; large movements, zooms, parallax and continuous motion are the targets. And reduced motion never replaces SC 2.2.2: auto-scrolling feeds need pause controls for everyone.
The misapplication: wrapping animations in a reduced-motion check that also skips rendering the final state — the chart stays at zero height because the “grow” step never runs.
Annotated code example
Permalink to "Annotated code example"// reduced-motion.spec.js
import { test, expect } from '@playwright/test';
test.use({ reducedMotion: 'reduce' }); // emulate the OS preference
test('dashboard renders final states without animation', async ({ page }) => {
await page.goto('/dashboard');
// KPI shows its final value immediately (no counting)
await expect(page.getByTestId('kpi-revenue')).toHaveText('€182,400', { timeout: 500 });
// Chart bars at full height without waiting for transitions
const heights = await page.locator('.bar').evaluateAll(bars => bars.map(b => b.getBBox().height));
expect(heights.every(h => h > 0)).toBe(true);
// No running CSS animations or long transitions
const running = await page.evaluate(() => document.getAnimations().filter(a => a.playState === 'running'
&& (a.effect.getTiming().duration > 200 || a.effect.getTiming().iterations === Infinity)).length);
expect(running).toBe(0);
});
test('live feed has a pause control regardless of preference', async ({ page }) => { // SC 2.2.2
await page.goto('/activity');
await expect(page.getByRole('button', { name: /pause/i })).toBeVisible();
});
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important; scroll-behavior: auto !important; }
}
Keyboard & AT behaviour
Permalink to "Keyboard & AT behaviour"Reduced motion affects visuals; its keyboard and AT implications are indirect:
| Situation | Expected behaviour | Why it matters |
|---|---|---|
| Sorting a table | Rows reorder instantly | Focus restoration is simpler without animations |
| Opening a panel | Appears without slide | Focus can move immediately |
| Smooth scrolling to an anchor | Jumps | Focus and scroll position align at once |
| Animated counters | Final value in DOM immediately | Screen readers never read intermediate numbers |
Integration context
Permalink to "Integration context"Skeleton shimmer is covered in accessible skeleton screens versus spinners; auto-updating feeds in pause, stop, hide controls for auto-updating data. Screenshot comparisons in reduced motion are less flaky — the same technique as forced-colors screenshots with Playwright.
Gotchas
Permalink to "Gotchas"JavaScript animations. CSS media queries do not stop requestAnimationFrame-based animations or chart library transitions. Check matchMedia('(prefers-reduced-motion: reduce)') in code and pass animation: false to chart libraries.
Counters read mid-animation. Animated numbers in live regions announce intermediate values. Always put the final value in the DOM text; animate visually only.
The blanket CSS reset. The common “0.01ms everything” rule can break components that depend on transitionend events. Test that components still finish their state changes.
Common mistakes
Permalink to "Common mistakes"- Skipping the final state along with the animation.
- Forgetting JavaScript-driven animations.
- Treating reduced motion as a substitute for pause controls.
- Not testing at all because “we only use small transitions”.
Framework notes
Permalink to "Framework notes"Chart.js: options.animation = false when reduced motion is preferred. ECharts: animation: false. Highcharts: chart.animation = false and plotOptions.series.animation = false. Framer Motion provides useReducedMotion(). In Angular, @angular/animations can be disabled per component with @.disabled.
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
new Chart(ctx, { type: 'bar', data, options: { animation: reduce ? false : { duration: 600 } } });
Worked example
Permalink to "Worked example"A finance dashboard animated every KPI counter and chart on load and after each refresh. A user with a vestibular condition reported feeling unwell after a few minutes. With reduced motion emulated in Playwright, tests showed 23 running animations on load and counters that announced intermediate values to screen readers. The team set chart library animation off under reduced motion, rendered final counter values directly, replaced shimmer with static placeholders, and added a pause control to the live activity strip for all users. The reduced-motion test now guards against regressions.
Testing checklist
Permalink to "Testing checklist"Design system notes
Permalink to "Design system notes"Expose a motion token set (durations, easing) that collapses to zero under reduced motion, and a useReducedMotion helper every component and chart wrapper uses. Make “respects reduced motion” part of each component’s checklist.
FAQ
Permalink to "FAQ"Is respecting reduced motion required for AA?
Not directly; SC 2.3.3 is AAA. But SC 2.2.2 (A) applies to auto-moving content, and respecting the preference is widely expected.
Should fades be removed too?
Short opacity fades are usually fine. Remove movement, scaling, parallax and continuous animation.
Should the app offer its own motion setting?
It helps users who cannot change OS settings (shared or managed machines). Default it to the OS preference.
Do screen readers care about animation?
Indirectly: animated text changes can produce extra announcements. Keep final values in the DOM.
How do I find all running animations?
document.getAnimations() lists CSS and Web Animations API animations; JavaScript-driven ones need code review.
What about video and animated GIFs in dashboards?
They need pause controls under SC 2.2.2 if they play automatically for more than five seconds.
How do I test motion that only happens on interaction, like sorting?
Drive the interaction in the test (click Sort) and assert that no animations are running immediately afterwards and that the final order is already in the DOM.
Related
Permalink to "Related"- Pause, stop, hide controls — moving content for everyone
- Skeleton screens versus spinners — loading motion
- Forced-colors screenshots — the same emulation approach