When a Playwright test passes with animations disabled but fails as soon as motion is enabled, the DOM is often not the problem. The elements exist, the route changed, the selector is correct, and the UI eventually reaches the expected state. What changes is timing, hit testing, and the sequence of visual states that happen between one assertion and the next.

That distinction matters. Many teams treat these failures as generic flakiness, then respond by adding sleeps, retry loops, or broader timeouts. Those fixes may reduce noise in the short term, but they also hide the real cause, which is usually one of three things: an action is happening before an element is actually actionable, an assertion is sampling the page while it is mid-transition, or the test is assuming a stable geometry that motion temporarily violates.

This guide is a debugging playbook for motion-heavy interfaces, especially when you see the pattern that Playwright tests fail with css animations enabled, but the same scenario looks stable when motion is removed. It also applies to view transitions testing in browsers that support the API, because the failure mode is similar, the DOM can be correct while the rendered state is still moving.

Why animations and transitions break otherwise correct tests

A modern UI animation usually changes one or more of these at runtime:

  • position, size, or transform of an element
  • opacity or visibility during fade in and fade out
  • pointer events during entrance and exit states
  • stacking context or overlay order
  • the timing of when a route, dialog, or panel becomes interactable

For a human, these changes are usually fine. For an automated test, each one can create a brief window where the target is technically present but not yet usable.

Playwright is designed to wait for actionability before clicks and typing. Its documentation describes the model: it checks that elements are visible, stable, enabled, and can receive events before performing actions. That helps a lot, but it does not solve every animation case. If a page keeps moving, the actionability conditions may never stabilize quickly enough, or they may stabilize on the wrong intermediate element.

A test can be logically correct and still be temporally wrong.

That is the central debugging idea here. If your assertion is too early, or your click lands during a moving overlay, the failure is about time, not selectors.

First, classify the failure mode

Before changing code, identify which kind of motion problem you have. The fix is different for each.

1. The action fails because the element is not yet clickable

Typical symptoms:

  • click times out
  • Playwright reports the element is obscured or moving
  • a hover opens a menu, but the click arrives before the menu settles
  • a dialog animates in, but the button is still covered by an overlay

This is usually an actionability issue.

2. The assertion fails because the UI is in an intermediate state

Typical symptoms:

  • text is present, but the panel is still fading out
  • the new route content is visible, but old content has not fully detached
  • the DOM contains the expected element, but the screenshot or computed style is still mid-transition

This is usually an assertion timing issue.

3. The test finds the wrong element because two states coexist briefly

Typical symptoms:

  • old and new navigation items both exist for a moment
  • duplicate labels appear during crossfade
  • a hidden template and a visible element share the same text

This is usually a locator design issue.

4. The test passes locally but fails in CI

Typical symptoms:

  • local machine is fast enough that the transition is over
  • CI browser runs slower or under different CPU contention
  • headless rendering exposes timing windows that are hard to see locally

This is usually a scheduling and environment issue.

Reproduce the failure with motion enabled and deterministic inputs

The first debugging mistake is to turn off motion because it makes the test pass. That is useful as a diagnostic, but not as the final answer.

Instead, keep motion enabled and try to make the failure repeatable.

Freeze nonessential sources of variability

Use fixed test data, a known viewport, and a stable network. If your app fetches data during the animation, mock or seed that data so the UI path stays the same across runs.

import { test, expect } from '@playwright/test';

test.use({ viewport: { width: 1280, height: 800 } });

test('opens the details panel', async ({ page }) => {
  await page.route('**/api/details', route => route.fulfill({
    json: { title: 'Example', status: 'ready' }
  }));

await page.goto(‘/dashboard’); await page.getByRole(‘button’, { name: ‘Open details’ }).click(); await expect(page.getByRole(‘dialog’)).toBeVisible(); });

The point is not to eliminate all motion. The point is to make the failure path narrow enough that you can observe it.

Capture traces and screenshots around the failure

Playwright trace files are often the fastest way to understand whether the problem is an action happening too early, or an assertion sampling the page mid-transition. When a test fails intermittently, trace data can show the DOM snapshot and action timeline around the failure.

If you are not already using tracing, enable it for the affected tests or for retries in CI. The Playwright trace viewer is particularly helpful when you need to see what the page looked like at the moment the action failed.

Run with reduced parallel noise

Flaky motion tests become harder to reason about when many suites compete for the same machine. Re-run the test with workers reduced, then on a single worker, so you can separate contention from the app’s own timing.

That does not fix the bug, but it tells you whether CI load is amplifying a real animation race.

Inspect the DOM and the rendered state separately

A recurring mistake is to assume that if the element exists in the DOM, it is ready. With motion, that assumption is often false.

Consider a modal that fades in using opacity and transform. The DOM node may exist immediately, but the browser may still consider it moving, covering the underlying button, or not yet hit-testable in the way your test expects.

In debugging, check three layers independently:

  • DOM presence, does the node exist?
  • Visibility and geometry, is it displayed where you expect?
  • Interactivity, can it receive pointer events right now?

You can inspect these via Playwright assertions or quick debug scripts.

typescript

const modal = page.getByRole('dialog');
await expect(modal).toBeVisible();
await expect(modal).toHaveCSS('opacity', '1');

This is not a universal fix. It is a way to confirm whether the animation is still in progress when the test is trying to interact.

If the element is visible but still not actionable, a common failure mode is an overlay or transform that keeps the click target displaced for a fraction of a second.

Understand what Playwright waits for, and what it does not

Playwright auto-waits for many actions, which is one reason teams choose it for test automation. But the waiting model has limits.

It waits for the target element to be attached, visible, stable, enabled, and receiving events. It does not infer your application’s semantic readiness unless you encode that readiness into the test.

For example, if a route transition uses a skeleton screen that fades out after data arrives, the test may need to wait for a semantic signal such as:

  • a spinner disappears
  • a loading region gains a data-state="ready"
  • an aria-live status announces completion
  • a network response is received and the UI reflects it

That is better than waiting for a generic animation duration, because it waits for the real condition the user needs.

Prefer waiting on app state, not animation duration.

Debug the actual motion source

Once you know the failure is timing-related, identify which motion primitive is involved.

CSS transitions

These often animate opacity, transform, height, or left/top. They can make elements appear clickable before layout or stacking has settled.

Common clues:

  • short duration, often 150 ms to 400 ms
  • failures cluster around entering and leaving components
  • the page works if you pause before clicking

CSS animations

These can be more complex than transitions, because they may run on loop or trigger keyframe changes that influence layout and click targeting.

Common clues:

  • repeated position shifts or pulses
  • test passes if animation is disabled globally
  • hover or focus states trigger movement

View transitions

The View Transitions API changes how old and new DOM states are visually blended during navigation or updates. The DOM may update correctly while the browser composites old and new states for a short period.

Common clues:

  • navigation assertions are correct, but screenshots disagree
  • both old and new content seem to exist temporarily
  • the browser appears to lag behind the route state

For view transitions testing, the key question is whether you should wait for the application route to settle, or for the visual transition to finish. Those are not always the same event.

Add targeted instrumentation before changing the test

If the failure is elusive, add temporary logging around the action and the expected state.

Log the route or state change

page.on('console', msg => console.log('[browser]', msg.text()));
page.on('framenavigated', frame => {
  if (frame === page.mainFrame()) console.log('navigated to', frame.url());
});

Observe whether the target is being covered

A common issue is a moving overlay or toast intercepting clicks. You can inspect the element receiving pointer events with browser devtools, or by temporarily checking the top element at the click point.

Measure the transition duration from CSS

When a transition is strongly suspected, inspect the relevant computed styles.

typescript

const panel = page.locator('[data-testid="side-panel"]');
const duration = await panel.evaluate(el => getComputedStyle(el).transitionDuration);
console.log(duration);

This tells you what the app is asking the browser to do, but not whether that duration matches the test’s expectation. Still, it is useful for confirming whether the failure window is 200 ms or 2 seconds.

Fix the test by waiting on the right condition

The best fix is usually not to suppress animations globally. It is to encode the real readiness condition.

Wait for a visible business-state signal

If a save button triggers a panel slide-out, wait for the result of the save, not for the slide-out animation itself.

typescript

await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByText('Saved')).toBeVisible();
await expect(page.getByRole('dialog')).toBeHidden();

This creates a stronger contract. The test is now asserting that the user-visible outcome happened, not just that a timer elapsed.

Wait for the element to stop moving, only if necessary

Sometimes the only reliable signal is that the target has finished its motion. In that case, assert the final state of a property involved in the transition.

typescript

await expect(page.getByTestId('drawer')).toHaveCSS('transform', 'none');

Use this sparingly. CSS values can vary across implementations, and over-constraining them can make tests fragile for the wrong reason.

Wait for animation end events in app code, then expose state to tests

If your app already tracks transition end internally, expose that through a stable DOM signal such as a data-state attribute.

```html
<div data-state="open" data-transition="idle">...</div>

Then assert on `data-state` or `data-transition`. This keeps the test readable and avoids binding it too tightly to exact animation mechanics.

## Decide whether to disable motion in tests

Many teams eventually ask whether they should just turn off CSS animations for end-to-end tests. The answer is sometimes yes, but only when the motion itself is not under test.

### Disabling motion makes sense when

- the test is about data flow, not animation behavior
- the animation is purely decorative
- the app has no reliance on motion for state correctness
- motion adds noise without adding coverage

In that case, a test-only stylesheet can remove transitions and animations.

typescript
```typescript
await page.addStyleTag({ content: `
  *, *::before, *::after {
    animation-duration: 0s !important;
    animation-delay: 0s !important;
    transition-duration: 0s !important;
    transition-delay: 0s !important;
    scroll-behavior: auto !important;
  }
`});

Disabling motion is risky when

  • the app uses animation to gate interactivity
  • transitions are tied to focus management or layering
  • you are validating real user behavior around dialogs, menus, or route changes
  • your test misses a regression that only appears during the animated state

The tradeoff is simple: removing motion reduces flakiness, but it also removes coverage of a class of interaction bugs. Teams should make that decision per test suite, not by default.

Treat view transitions as a separate category

View transitions are not just another animation. They can preserve old visual content while new DOM content is already present. That means a traditional assertion like toHaveText() may pass, while a screenshot or click target still reflects the transition frame.

For route-based UIs, decide what the test is verifying:

  • functional transition, the new route data is loaded and interactable
  • visual transition, the browser completes the cross-fade or shared-element motion

Do not mix those goals in one assertion unless the test really needs both.

If you are testing navigation with shared elements or cross-page animations, assert the route change first, then the final stable UI state, and only then check visual details if they matter.

Common anti-patterns that make the problem worse

Using fixed sleeps

waitForTimeout(500) can make a flaky test seem stable, but it only guesses at the right delay. If CI slows down or the animation changes, the guess breaks.

Clicking immediately after hover or navigation

Hover menus and route transitions often need one more frame than the test expects. If a click follows directly after another motion-triggering action, check whether the target is stable first.

Overusing force clicks

force: true bypasses some safeguards. If an element is actually covered by an overlay or still moving, a forced click can hide the fact that the app is not yet ready.

Locating by text that exists in multiple states

During animations, both the entering and exiting elements may contain similar text. Prefer roles, test IDs, or scoped locators when two states briefly overlap.

A short debugging checklist

When you see animation-related failures, walk through this sequence:

  1. Re-run with motion enabled and deterministic data.
  2. Inspect trace output around the failing step.
  3. Identify whether the failure is an actionability issue, assertion timing issue, or locator ambiguity.
  4. Check for overlays, opacity changes, transforms, or hidden duplicate elements.
  5. Replace arbitrary waits with a state-based condition.
  6. Decide whether the suite should disable motion for this path, or whether motion is part of the behavior under test.

If the DOM is correct but the test still fails, ask what the browser is doing between the DOM update and the user-visible stable state.

Example: fixing a flaky drawer test

Suppose a test opens a navigation drawer and clicks a link inside it. The drawer animates from off-screen to on-screen using transform: translateX(...).

A brittle version might look like this:

typescript

await page.getByRole('button', { name: 'Menu' }).click();
await page.getByRole('link', { name: 'Billing' }).click();

If the link is mounted immediately but still off-screen or covered while the drawer animates, the click can fail.

A more robust version waits for the drawer to finish opening or for a stable semantic state.

typescript

await page.getByRole('button', { name: 'Menu' }).click();
await expect(page.getByTestId('drawer')).toHaveAttribute('data-state', 'open');
await page.getByRole('link', { name: 'Billing' }).click();

If the application does not expose a state attribute, it may be worth adding one. That is often cleaner than trying to assert on animation timing directly.

Example: handling route transitions with shared content

In a route transition, the old page may remain visible while the new page is already loading. If the test asserts on a heading too early, it may catch the old page or a transitional composite frame.

typescript

await page.getByRole('link', { name: 'Reports' }).click();
await expect(page).toHaveURL(/\/reports/);
await expect(page.getByRole('heading', { name: 'Reports' })).toBeVisible();

This sequence separates the route change from the final stable UI. If the app uses view transitions, that distinction is often what keeps the test reliable.

Where Selenium and Cypress fit into this discussion

The underlying problem is not specific to Playwright. Selenium and Cypress face the same class of animation timing issues, because all browser automation has to deal with the gap between the application state and the browser’s rendered state.

The practical difference is in how much explicit waiting you need to write and how much observability the framework gives you when a transition fails. Selenium tends to require more manual control over waits. Cypress has its own retry model, which can help with some timing problems but still leaves you with actionability and overlay issues when animations are involved.

If you are comparing software testing approaches across tools, the useful criterion is not which framework claims to handle motion best. It is which one gives your team the clearest way to express the actual readiness condition, inspect failures, and maintain the tests over time.

Decision criteria for teams

Use this checklist when deciding how to handle motion in a test suite:

  • Does the test verify the animation itself, or only the resulting state?
  • Is the motion purely decorative, or does it affect clickability and layout?
  • Can the app expose a stable state attribute for the test to wait on?
  • Are failures local to one component, or widespread across the suite?
  • Does the CI environment make timing windows larger than local development?
  • Will disabling motion hide real regressions that matter to users?

If most answers point to business state, wait on that state. If the animation is part of the feature, test the final rendered behavior and, where needed, a separate visual assertion.

A practical rule of thumb

When you are debugging motion-related flakes, start by assuming the app is correct and the test is early. Then prove otherwise.

That rule prevents two opposite mistakes. It keeps you from masking real problems with arbitrary waits, and it keeps you from overreacting to a test that only needs a better readiness condition.

The goal is not to make every test aware of every animation. The goal is to make the test express the moment when the UI is genuinely ready for the next action.

If you do that, the failures stop looking random. They become understandable timing bugs, and once they are understandable, they are usually fixable.