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-motion is 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 with reducedMotion: 'reduce'.
Motion in data UIs and what reduced motion should do Matrix of motion types — chart entry animation, counting numbers, skeleton shimmer, auto-scrolling feed, sort row transitions, smooth scroll — against default behaviour and reduced-motion behaviour. Motion in data UIs and what reduced motion should doDefaultReduced motionChart entry animationBars grow from zeroRender final stateCounting numbersCount up over 1 sShow final numberSkeleton shimmerAnimated gradientStatic placeholderAuto-scrolling feedScrolls continuouslyPaused; pause control anywaySort row transitionsRows slideInstant reorderSmooth scroll to anchorAnimated scrollJump
Reduced motion removes decoration but never removes information.

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.

Removing motion versus removing content Comparison: a broken reduced-motion implementation skips the animation and its end state; a correct one jumps straight to the final state. Removing motion versus removing content✗ Broken: skip the animation entirelyBars stay at height zeroCounter stays at 0Skeleton never replacedInformation lost✓ Correct: jump to the end stateBars render at full heightCounter shows 12,480Content appears without shimmerOnly motion lost

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
A chart load with and without reduced motion Timeline comparing a default chart load where bars grow over 800 ms and a counter counts up over 1 second, with reduced motion where both show final values at once. A chart load with and without reduced motion0 msData arrivesdefault: animations start; reduced: final state rendered400 msMid-animationdefault: bars half height, counter at 6,240800 msBars completedefault only1000 msCounter completedefault only; reduced shows 12,480 since 0 ms
Same information at 0 ms with reduced motion.

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.

Permalink to "Related"

← Back to Visual Regression & Theme Testing