Motion Testing & Automation

Part of Accessible Motion Architecture.

Motion is the part of a design system that decays quietly. A reduced-motion gate that was correct when a component shipped survives exactly until someone adds a second animation to it, and nothing in a normal review catches that: the diff looks fine, the component looks fine, and the failure is only visible to a user who has the preference set. Automated checks are the only practical way to keep a motion system honest across a team and across time.

The good news is that motion is unusually testable. The preference is an emulatable media feature, animations are enumerable through getAnimations(), and the two states — full motion and reduced motion — are both deterministic. A useful suite is a few dozen lines, not a framework.

Concept: three things worth asserting

Testing “does the animation look right” is a losing game. Three narrower assertions catch nearly every regression that matters.

That the reduced path exists. Under emulated prefers-reduced-motion: reduce, no element should be running a long, travelling animation. This is a property of the page, not of a component, and it can be asserted globally: enumerate every running animation and check its effect timing and its animated properties.

That the end state is identical. The reduced path and the full path must leave the interface in the same visual and DOM state. A gate that hides an element instead of settling it is the most common serious bug in this area, and it is invisible unless the two branches are compared.

That the indicator survives. Focus and state feedback must remain visible when motion is removed. This is a keyboard walk with the preference emulated, asserting that each focused element has a visible indicator — exactly the audit described in focus and state motion accessibility, performed by a script rather than by hand.

Everything else — durations, curves, whether the fade is 200 ms or 240 ms — is a design decision, not a correctness property, and does not belong in an assertion.

What to assert, and what to leave aloneAssertions belong on properties that are either true or false; everything subjective belongs in review.What to assert, and what to leave aloneAssert it?HowNo travelling motionunder reduceYesEnumerate getAnimations() withthe media feature emulatedBoth paths end in thesame stateYesCompare computed styles aftereach path settlesFocus indicatorvisible under reduceYesKeyboard walk plus a contrastcheckFlash rate under threeper secondYesCount iterations against effectdurationExact duration of afadeNoA design decision; review it, donot assert itEasing curve feelsrightNoSubjective; belongs in designreview
Assertions belong on properties that are either true or false; everything subjective belongs in review.

Execution model: what a headless browser can and cannot see

Automated motion testing runs in a real browser, and that browser has three capabilities the test relies on.

Media feature emulation. Every automation framework built on the Chrome DevTools Protocol can force prefers-reduced-motion, so both branches are reachable in one run without changing an operating-system setting. This is the foundation: without it, the reduced path is simply never exercised.

The animation inventory. document.getAnimations() returns every Animation currently affecting the document — CSS transitions, CSS animations and scripted effects alike — with their timing, playback state and target. It does not force layout, so it is safe to call in a loop. This is what turns “is anything animating?” from a screenshot comparison into a data assertion.

Deterministic settling. Awaiting Promise.allSettled(document.getAnimations().map(a => a.finished)) waits for every animation to finish rather than sleeping for an arbitrary duration. Tests that sleep are flaky by construction; tests that await the platform’s own completion signal are not.

What the browser cannot tell you is whether motion is comfortable. Vestibular risk is a function of area, distance and velocity in the user’s visual field, and while a test can measure travel distance as a fraction of the viewport — which is worth doing — the threshold itself is a judgement recorded in vestibular-safe motion patterns, not something a tool derives.

One test run, both branchesThe same interaction is driven twice; the assertions differ per branch, the end state does not.One test run, both branchesEmulateno-preferenceDrive theinteractionAwait allanimationsRecord endstateEmulate reduce,repeatCompare states
The same interaction is driven twice; the assertions differ per branch, the end state does not.

Tooling reference

Capability API or flag Where it runs Notes
Force the motion preference page.emulateMedia({ reducedMotion: 'reduce' }) Playwright Puppeteer uses emulateMediaFeatures
Enumerate running effects document.getAnimations() In-page Includes CSS and scripted animations
Wait for settling Promise.allSettled(...finished) In-page Replaces every arbitrary waitForTimeout
Inspect one effect animation.effect.getTiming() In-page Duration, iterations, delay, easing
Read animated properties animation.effect.getKeyframes() In-page Lets a test flag layout-tier properties
Measure travel getBoundingClientRect() before and after In-page Compare against viewport dimensions
Freeze motion for screenshots animations: 'disabled' Playwright screenshots Avoids flaky visual diffs
Axe accessibility rules axe.run(document, { runOnly: [...] }) In-page Contrast checks for indicators
CI gate Exit code from the runner Pipeline Fail the build, do not just report

Annotated examples

Asserting that nothing travels under reduced motion

// Intent: with the preference set, no running animation may move an element
// more than a token distance, and none may loop.
test('reduced motion removes travel', async ({ page }) => {
  await page.emulateMedia({ reducedMotion: 'reduce' });
  await page.goto('/components/notifications/');
  await page.getByRole('button', { name: 'Show notification' }).click();

  const offenders = await 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();
        // Crude but effective: any transform keyframe with a translate over
        // 10% of the viewport, or any infinite effect, is a failure.
        const travels = frames.some((frame) => {
          const match = /translate[^)]*\(([-\d.]+)px/.exec(frame.transform || '');
          return match && Math.abs(Number(match[1])) > viewport * 0.1;
        });
        const loops = timing.iterations === Infinity;
        return travels || loops
          ? { target: animation.effect.target?.className, travels, loops }
          : null;
      })
      .filter(Boolean);
  });

  expect(offenders).toEqual([]);
});

Comparing the two branches’ end states

// Intent: whatever the motion does, both paths must finish in the same place.
async function settledState(page, selector) {
  await page.evaluate(() =>
    Promise.allSettled(document.getAnimations().map((a) => a.finished))
  );
  return page.$eval(selector, (element) => {
    const styles = getComputedStyle(element);
    const box = element.getBoundingClientRect();
    return {
      opacity: styles.opacity,
      visibility: styles.visibility,
      display: styles.display,
      top: Math.round(box.top),
      left: Math.round(box.left),
    };
  });
}

test('both motion paths settle identically', async ({ page }) => {
  await page.emulateMedia({ reducedMotion: 'no-preference' });
  await page.goto('/components/panel/');
  await page.getByRole('button', { name: 'Open panel' }).click();
  const full = await settledState(page, '.panel');

  await page.emulateMedia({ reducedMotion: 'reduce' });
  await page.reload();
  await page.getByRole('button', { name: 'Open panel' }).click();
  const reduced = await settledState(page, '.panel');

  expect(reduced).toEqual(full);
});

Keeping the indicator visible

// Intent: with motion removed, every keyboard stop still shows something.
test('focus indicators survive reduced motion', async ({ page }) => {
  await page.emulateMedia({ reducedMotion: 'reduce' });
  await page.goto('/');

  for (let i = 0; i < 25; i++) {
    await page.keyboard.press('Tab');
    const visible = await page.evaluate(() => {
      const el = document.activeElement;
      if (!el || el === document.body) return true;   // ran out of stops
      const styles = getComputedStyle(el);
      const hasOutline = styles.outlineStyle !== 'none' &&
        parseFloat(styles.outlineWidth) > 0;
      const ring = getComputedStyle(el, '::after');
      const hasRing = ring.content !== 'none' && parseFloat(ring.opacity) > 0.1;
      return hasOutline || hasRing || styles.boxShadow !== 'none';
    });
    expect(visible).toBe(true);
  }
});

Rendering Impact: none — these run in CI, not in production. The point of listing them here is that the assertions read the same properties the compositor-safe property set is defined in terms of, so a failure names the exact declaration to change.

DevTools workflow

Automation is the second step; the first is confirming by hand that the assertion is worth making.

  1. Open Rendering and set Emulate CSS media feature prefers-reduced-motion to reduce. Reload and exercise the component.
  2. In the console, run document.getAnimations() while the interaction is mid-flight. The array is what your test will assert on — if it is empty when you expected an entry, the test would be asserting nothing.
  3. Inspect one entry: document.getAnimations()[0].effect.getTiming() and .getKeyframes(). These are the exact shapes the assertions consume, so writing the test against what you see here avoids guessing at the structure.
  4. Run await Promise.allSettled(document.getAnimations().map(a => a.finished)) and watch the interface settle. If it never resolves, something is looping infinitely — which is itself a finding under reduced motion.
  5. Repeat with the preference set to no-preference and compare. The two runs are the two branches your suite will compare automatically.

Failure modes and fixes

The test passes because nothing was animating. An assertion over an empty array is always true. Guard every motion test with a positive check first: under no-preference, getAnimations() must be non-empty after the interaction. Then assert the reduced-motion property.

The suite is flaky on CI but stable locally. Almost always a fixed timeout racing a slower machine. Replace every sleep with an await on finished, and disable animations for screenshot comparisons rather than trying to time them.

The reduced-motion run passes but real users still see motion. The test emulated the media feature, which the cascade honours — but scripted effects read the preference themselves. If the component animates through element.animate(), the test is exercising a path the cascade never touches. Assert on getAnimations() rather than on stylesheet state, because it sees both.

A component regressed and no test failed. The suite tested a page, and the new animation is on a different page. Motion assertions generalise well: rather than one test per component, run the global “nothing travels under reduce” assertion across a list of representative routes. It costs one navigation each and catches the whole class.

Visual regression tests fight the animations. Freeze them. Playwright’s screenshot option animations: 'disabled' fast-forwards animations to their end state and disables looping ones, which makes the comparison deterministic without disabling the motion under test elsewhere in the suite.

Adding motion checks to an existing pipelineEach step is independently useful, so the suite can grow without a big-bang rewrite.Adding motion checks to an existing pipeline1Add one test that emulates reduce and asserts no infinite or travellinganimation on the busiest route.Catches the whole class of missing gates2Add the end-state comparison for two or three interactive components.Catches gates that hide instead of settling3Add the keyboard walk with the preference emulated.Catches indicators that only existed as an animation4Wire the runner's exit code into the build so a regression blocks themerge.The suite stops being advisory
Each step is independently useful, so the suite can grow without a big-bang rewrite.

Accessibility and reduced-motion notes

Two cautions about what these tests mean.

A green suite is evidence that the gates exist, not that the motion is appropriate. Nothing here measures whether a permitted animation is comfortable, whether an essential animation genuinely is essential, or whether the reduced experience is good rather than merely present. Those remain review questions, informed by the classification work in reduced-motion fallback patterns.

Second, emulation is not the same as the operating-system setting for every code path. A component that reads the preference once at module load will pass a test that emulates before navigation and fail for a user who changes the setting mid-session. Where that matters, add one test that flips the emulated preference after the page has loaded and asserts that running effects stop — the behaviour described in detecting prefers-reduced-motion in JavaScript.

What a motion suite cannot tell you

It is worth being explicit about the boundary, because a green suite is easy to mistake for a finished job.

The assertions above check that gates exist, that end states agree, and that indicators survive. None of them can judge whether a permitted animation is appropriate: whether a 400 ms slide is too slow for a menu, whether a hover lift adds anything, whether the reduced experience is pleasant rather than merely present. Those are design questions, and the honest answer is that they need a person.

There is a second boundary that matters more. A test can measure the pixel distance in a keyframe; it cannot measure how much of the visual field that distance covers on the device in the user’s hands, at the viewport they actually use, at the zoom level they have set. The vestibular thresholds are expressed as fractions of the viewport for exactly this reason, and translating them into an assertion always involves an approximation.

The productive way to hold both facts is to treat the suite as a ratchet rather than as a verdict: it guarantees that what was fixed stays fixed, and it frees review time for the questions only review can answer. A team that runs the checks and never looks at the reduced experience has automated the wrong half of the problem.

Frequently asked questions

Can I test motion accessibility with a static linter?

Only partially. A linter can find animation declarations that sit outside a preference query, which is genuinely useful as a first pass. It cannot see scripted effects, cannot tell whether the reduced branch settles in the right place, and cannot check that a focus indicator survives — all of which need a real browser.

Is emulating the media feature the same as the user’s OS setting?

For the cascade, yes: the browser resolves the media query against the emulated value exactly as it would against the real one. The difference appears in scripted code that samples the preference once at startup, which is why one test should flip the emulated value after load and assert that running effects respond.

How do I stop animations making visual regression tests flaky?

Disable them at screenshot time rather than in the page. Playwright’s screenshot option fast-forwards finite animations to their end state and stops infinite ones, so the captured frame is deterministic while the rest of the suite still exercises real motion.

What should fail the build versus produce a warning?

Fail on anything that is a correctness property: a travelling or infinite animation under reduce, a mismatched end state, a missing focus indicator, a flash rate above three per second. Warn on things that are judgement calls, such as a duration outside the house range.

Do these tests belong with unit tests or end-to-end tests?

End-to-end, because they need a real browser with real CSS applied. They are cheap to run — a handful of navigations — so a representative route list is usually enough rather than a test per component.