July 24, 2026
Why Browser Tests Fail When CSS Motion Preferences Differ Between Local and CI Environments
Learn why browser tests fail with reduced motion settings, how CSS prefers-reduced-motion changes timing in local and CI environments, and how to stabilize animation-related test flakiness.
Browser tests that look stable on a developer laptop can become inconsistent in CI when motion preferences differ. The root cause is usually not “animation is bad”, but a mismatch between what the application renders and what the test expects at a given moment. A page that shortens transitions, disables animations, or changes timing under prefers-reduced-motion can expose timing assumptions that were accidentally hidden on a local machine.
This is one of those failure modes that sits at the boundary between CSS, browser behavior, and test synchronization. It is especially common in UI suites that rely on visible text, element positions, overlays, or clickability immediately after a route change or interaction. When teams say browser tests fail with reduced motion settings, they are often describing a timing bug that only becomes visible once the browser stops animating the DOM in the same way as before.
What actually changes when motion preferences differ
The CSS media feature prefers-reduced-motion lets a page adapt when the user requests less motion. In practice, that often means one of these patterns:
- transitions are shortened or removed
- animations are disabled
- scrolling behavior changes from smooth to instant
- elements appear immediately instead of sliding, fading, or scaling in
This matters because many tests implicitly assume a sequence of visual states. For example:
- click a menu button
- wait for the panel animation to finish
- locate a menu item
- click the item
That sequence works when the animation duration is consistent. It becomes fragile when the duration changes to zero, or when a framework applies a different class list under reduced motion.
A test that only passes because the UI is still moving is not synchronized with application state, it is synchronized with elapsed time.
Why local and CI behave differently
The difference is usually environmental, not mysterious. Common causes include:
1. The browser inherits different motion preferences
A local desktop browser can inherit the operating system preference, while CI often runs in a headless container with a different default. Depending on how the test runner launches the browser, the page may see no-preference locally and reduce in CI, or the reverse.
2. Headless rendering changes timing
Headless mode can render and paint differently from a visible browser. The app code may not change, but animation completion, layout stabilization, and event ordering can be slightly different. That is enough to reveal a race condition in a test that uses fixed sleeps.
3. Test infrastructure often disables motion intentionally
Some teams configure CI to reduce motion globally to make tests more deterministic. That is a reasonable choice, but it requires tests to wait for state, not animation duration. If the suite was written around visible transitions, the move to reduced motion exposes hidden coupling.
4. Browser defaults vary by version and channel
Chrome, Chromium, Firefox, and WebKit do not always interpret the surrounding environment in identical ways. If one local developer uses a branded browser and CI uses a containerized Chromium build, the same test can observe different timing behavior.
The relevant broader context is continuous integration, where each run should be repeatable and isolated, but browser rendering remains a stateful client-side process rather than a pure function. See continuous integration for the operational model.
Common failure modes caused by reduced motion
1. Element exists, but is not yet interactable
A test may find a button in the DOM, then fail on click because another element still covers it, or because the target is mid-transition. Reduced motion changes the duration of that intermediate state, so the test may fail sooner or later than before.
2. Locators resolve before content is actually ready
Some components mount their DOM immediately, then animate content into place. A locator can succeed while the element remains visually hidden, offset, or clipped.
3. Assertions inspect transient classes or styles
Tests that assert on CSS classes such as enter-active, fade-in, or translate-x-0 often become brittle when those classes are skipped or replaced under reduced motion.
4. Scroll behavior changes timing
If a test uses smooth scrolling, it may wait for an element to come into view. Under reduced motion, that scroll might be instant, changing the timing of follow-up actions.
5. Overlay and dialog timing becomes inconsistent
Modal dialogs and dropdowns often fade in, trap focus, and become clickable in separate steps. If reduced motion removes the fade, the test may need to wait for focus management or ARIA state instead of visibility alone.
A concrete example with CSS timing
Consider a dialog that transitions into view:
.dialog {
opacity: 0;
transform: translateY(8px);
transition: opacity 180ms ease, transform 180ms ease;
}
.dialog[data-open=”true”] { opacity: 1; transform: translateY(0); }
@media (prefers-reduced-motion: reduce) { .dialog { transition: none; transform: none; } }
A test written with a fixed delay may pass locally:
typescript
await page.getByRole('button', { name: 'Open dialog' }).click();
await page.waitForTimeout(200);
await page.getByRole('button', { name: 'Confirm' }).click();
That test depends on the dialog taking roughly 180 ms to settle. If motion is reduced, the dialog may appear immediately, or the DOM may update in a slightly different order. The better approach is to wait for a stable semantic signal:
typescript
await page.getByRole('button', { name: 'Open dialog' }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await expect(page.getByRole('button', { name: 'Confirm' })).toBeEnabled();
await page.getByRole('button', { name: 'Confirm' }).click();
The test now follows application state, not animation duration.
How to reproduce the problem intentionally
Before changing test code, make the mismatch visible.
In Playwright
You can emulate reduced motion in a browser context:
import { test, expect } from '@playwright/test';
test.use({ reducedMotion: ‘reduce’ });
test('opens the menu', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Menu' }).click();
await expect(page.getByRole('menu')).toBeVisible();
});
This is useful because it turns an environmental difference into a controlled test input. If the suite only fails with reducedMotion: 'reduce', the bug is likely in the app or the test synchronization, not in the CI runner itself.
In Chromium-based CI setups
Some teams set the browser launch flag or environment intentionally. The exact mechanism varies by runner, but the diagnostic goal is the same, verify whether tests are sensitive to reduced motion and whether the application behavior is semantically correct in both modes.
In CSS debugging
Use the browser devtools to toggle motion preferences and inspect whether the page still reaches a usable state without transitions. The goal is not to preserve the animation effect, it is to confirm that the accessibility branch and the default branch both leave the UI in a consistent state.
Why the bug is often in the test, not just the CSS
Reduced motion reveals two distinct issues.
Issue 1, the application makes a valid visual change but does not expose a stable state signal
If the DOM changes only through animation classes, the test has nothing reliable to wait for. That is a design problem in the component API.
Issue 2, the test is tied to a visual transition instead of a user-relevant condition
A test should generally care whether the menu is open, whether the dialog is focusable, whether the route is active, or whether the data has loaded. It should not care whether a fade animation lasts 180 ms or 0 ms.
This distinction is important because test automation, in the broader sense, aims to verify behavior through repeatable checks rather than through incidental rendering details. See test automation for the general principle.
Practical debugging checklist
When a suite becomes flaky only in CI or only locally, check the following in order.
1. Compare motion preference in each environment
Inspect what the browser sees, not what the operating system says. In application code or a temporary debug page:
console.log(window.matchMedia('(prefers-reduced-motion: reduce)').matches);
If local and CI differ, you have found a meaningful variable.
2. Search for fixed waits
Look for waitForTimeout, sleep, cy.wait(200), or similar delays that approximate animation duration. These often fail when motion settings change.
3. Replace animation-based assertions with state-based assertions
Prefer:
toBeVisible()toBeEnabled()toHaveAttribute('aria-expanded', 'true')toHaveURL()toHaveText()after the relevant network or DOM update
Avoid asserting that a particular CSS transition ran unless the test exists specifically to verify motion behavior.
4. Check for overlay and focus timing
Dialogs, tooltips, and dropdowns can look present before they are fully interactive. If the click fails, inspect the stacking context, pointer events, and focus trap timing.
5. Verify the app under reduced motion manually
Use a browser setting or devtools emulation and walk through the workflow. If the UI is confusing or incomplete without motion, the product may have an accessibility defect, not only a test defect.
A more stable Playwright pattern
A test often becomes more reliable when it waits for the exact state transition it needs.
typescript
await page.getByRole('button', { name: 'Open settings' }).click();
const dialog = page.getByRole('dialog', { name: 'Settings' });
await expect(dialog).toBeVisible();
await expect(dialog.getByRole('button', { name: 'Save changes' })).toBeEnabled();
If the app uses an async render followed by a CSS transition, the visibility assertion handles both motion and reduced-motion cases. The test does not assume that animation completion is the same as readiness.
A Selenium pattern for the same issue
Selenium users often solve the same problem with explicit waits tied to the DOM state rather than sleep-based timing.
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
wait = WebDriverWait(driver, 10) driver.find_element(By.CSS_SELECTOR, ‘button[aria-label=”Open menu”]’).click() wait.until(EC.visibility_of_element_located((By.CSS_SELECTOR, ‘[role=”menu”]’)))
The important part is not the framework, it is the condition. Visibility, enabled state, and accessible attributes are more durable signals than elapsed milliseconds.
When reduced motion is a product requirement
For some teams, supporting prefers-reduced-motion is not just a test concern, it is a product requirement. That changes how you should think about the suite.
If motion can be disabled, the application must still satisfy these properties:
- content reaches a final visible state
- focus is preserved or moved appropriately
- keyboard navigation still works
- clickable targets remain discoverable
- route changes do not depend on animation completion
This is a good place to distinguish between styling and state. A component can animate between states, but the state itself should be independently observable through DOM, ARIA, or URL changes.
How CI rendering differences amplify the problem
CI often introduces several sources of nondeterminism at once:
- fresh browser profiles
- limited GPU or no GPU acceleration
- headless mode
- containerized fonts and layout metrics
- different default viewport sizes
- different motion preferences
Any one of these can shift layout or timing enough to expose a weak test. When a test is already coupled to animation duration, CI rendering differences are a common reason it fails intermittently.
A useful mental model is that motion settings are not the cause of flakiness by themselves. They are a stress test for the suite’s synchronization strategy.
If disabling motion breaks a test, the test was depending on an implementation detail that users should not need to care about.
How to reduce animation-related test flakiness systematically
Use semantic locators
Prefer role-based and label-based queries over brittle CSS selectors. If the UI changes motion but not meaning, semantic locators remain valid.
Make readiness explicit in the component
If a dialog becomes usable only after a mount sequence, expose a clear signal such as aria-expanded, aria-hidden, or a route change. Tests can wait on that signal.
Avoid asserting on intermediate CSS classes
The classes that drive transitions are often implementation details. They can vary between reduced and default motion branches.
Keep animation durations short and consistent
Longer animations increase the chance that tests race with UI state. Even if the final fix is to improve synchronization, short transitions reduce the window for failure.
Test both branches deliberately
Run a small subset of tests with reduced motion enabled and a representative subset with default motion. That combination catches regressions in both accessibility behavior and standard interaction timing.
Treat waitForTimeout as a debugging tool, not a permanent solution
A fixed delay can help you confirm a hypothesis, but it should rarely remain in production tests. It makes the suite slower and still leaves it brittle.
A decision rule for debugging
If a test fails only when motion is reduced, ask three questions:
- Is the UI still functionally correct without animation?
- Is the test waiting for a meaningful state, or only for time to pass?
- Is there a better observable, such as visibility, ARIA state, or network completion?
If the answer to the first question is no, the application may need an accessibility fix. If the answer to the second is no, the test should be rewritten. If the answer to the third is yes, prefer the better observable.
Practical conclusion
Reduced motion settings do not create flaky tests out of nowhere, they reveal a hidden dependency between timing and behavior. When local and CI differ, browser tests fail with reduced motion settings because the suite is often observing animation completion instead of application state.
The durable fix is straightforward, but not always trivial, make readiness explicit, wait on semantic conditions, and verify that the app behaves correctly when motion is minimized or removed. That approach usually improves both accessibility and reliability at the same time.
For teams maintaining large UI suites, this is one of the most effective places to reduce noise. The work pays off twice, first by stabilizing CI rendering differences, and second by making the browser tests closer to the way users actually experience the product.