Modern frontend routing often looks smooth to users and messy to automation. A click that triggers a route change may start a CSS View Transition, keep the outgoing page alive for a short period, animate DOM snapshots, and swap the visible route only after several frames. That is exactly the kind of behavior that makes browser tests feel unstable even when the app is working as intended.

If you have seen Playwright tests fail with view transitions, the underlying issue is usually not “Playwright is flaky.” It is a mismatch between how the app expresses state changes and how the test asserts state. The UI can be in a legitimate intermediate state, but the test is asking for a locator, an assertion, or a click at the wrong moment.

This article explains the timing and locator problems that CSS View Transitions and animated route changes introduce, how those problems show up in Playwright, and how to stabilize tests without hiding real regressions.

What changes when an app uses CSS view transitions

CSS View Transitions, defined by the browser and documented in the CSS View Transitions API, let the browser animate a transition between two DOM states. Framework routing libraries may also animate route exits and entrances using regular CSS transitions, opacity fades, transforms, or delayed unmounting.

From a test perspective, the important detail is this: the old DOM may remain in place long enough to be visible, clickable, or queryable after navigation starts. The new DOM may exist before it is fully visible or interactable. During the overlap, there can be duplicate text, duplicate buttons, detached nodes, and elements with zero opacity or pointer-events disabled.

A test that assumes navigation is instantaneous will often be wrong in a world where the app intentionally keeps two route states alive at the same time.

That creates a different failure profile than classic “network is slow” flakiness. The route may already be logically changed, but the visible and interactive state is still settling.

Why Playwright is exposed to this class of flake

Playwright is designed to wait for actionability and stable conditions before interacting with elements, and its documentation is explicit about auto-waiting and locator-based testing. That helps a lot, but it does not remove ambiguity when the page intentionally contains transitional states.

Common reasons Playwright tests fail with view transitions include:

  • A locator matches both the outgoing and incoming route during overlap.
  • A click happens while the target is still animating, covered, or not receiving pointer events.
  • An assertion runs before the new route has finished rendering accessible text.
  • A test waits for URL change, but the application transitions before the final content is ready.
  • A node is detached and replaced as part of the animation, so a stored element handle becomes stale.

The key point is that Playwright can only reason about what exists in the DOM and what is currently actionable. If the app uses animation to delay or obscure state changes, the test needs a more explicit synchronization strategy.

Common failure patterns you will actually see

1. Duplicate text or duplicate role matches

During a transition, both the outgoing and incoming route can contain the same heading, button label, or nav item. A locator like this may become ambiguous:

typescript

await page.getByText('Settings').click();

If two “Settings” nodes exist for a moment, the click can fail with a strict mode violation, or worse, target the wrong copy if the locator is not strict enough.

A similar problem appears with role-based locators:

typescript

await page.getByRole('button', { name: 'Save' }).click();

If the app renders a hidden transition layer with duplicated controls, the locator may match more than one candidate, or it may match the visible one sometimes and the outgoing one other times depending on timing.

2. Element exists, but is not yet actionable

A button can be in the DOM but covered by the old screen, fading in, or blocked by pointer-events: none. Playwright may wait for visibility, but visibility alone is not enough if the animation is still moving the element into place.

Typical symptoms include timeouts on clicks that feel like they should work:

typescript

await page.getByRole('link', { name: 'Reports' }).click();

The app may need another frame or two before the link is truly interactable.

3. Detached node errors after navigation

When route changes unmount components and mount new ones, a previously located element can go stale. This is common when tests store element handles rather than using locators.

typescript

const button = await page.getByRole('button', { name: 'Continue' }).elementHandle();
await button?.click();

That pattern is fragile in animated route changes because the underlying node can disappear between the handle lookup and the click.

4. Assertions fire too early

A test may wait for URL change and then immediately assert on visible content:

typescript

await expect(page).toHaveURL(/\/dashboard/);
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();

If the app updates the history state before the transition finishes, the URL assertion can pass while the heading is still animating in, or still hidden behind the transition layer.

5. The test clicks the wrong element because the old route is still in the tree

This often happens when the outgoing route remains mounted for a fade-out. The DOM tree may contain two copies of a menu item, two forms, or two different backdrops. A click on the “visible” copy may be intercepted by an overlay created for the transition.

First debugging move, prove what is happening in the browser

Before changing test code, confirm whether the app is preserving both route states or using a transition overlay. In practice, the best evidence is a trace, a screenshot sequence, and the DOM at the moment of failure.

Playwright trace and video are useful here because they show whether the element was visible, covered, or duplicated at the time of interaction. If you are not already using traces in CI, that is usually the first operational gap to close.

A simple failing test can be made easier to inspect with tracing:

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

test.beforeEach(async ({ context }) => { await context.tracing.start({ screenshots: true, snapshots: true }); });

test.afterEach(async ({ context }) => { await context.tracing.stop({ path: ‘trace.zip’ }); });

Then inspect whether the old and new route overlap, whether an overlay exists, and whether the target node is covered or hidden during the failure window.

If you need a fast diagnosis, ask these questions:

  • Does the locator match more than one element during transition?
  • Is the target in the DOM but not visible yet?
  • Is an overlay or pseudo-element intercepting clicks?
  • Does the outgoing route stay mounted while the new route appears?
  • Is the test waiting on URL when it should wait on route content or an app-level signal?

Stabilization tactics that work without hiding real bugs

Prefer semantic route readiness over arbitrary sleeps

page.waitForTimeout() is the wrong fix for this class of problem. It may reduce failures temporarily, but it creates brittle timing dependence and slows the suite.

Instead, wait for a state that represents the route being ready. That might be:

  • a page heading that uniquely identifies the new route
  • a loading indicator disappearing
  • a DOM attribute that marks the route as settled
  • a transition promise exposed by the app for test environments

Example:

typescript

await page.getByRole('heading', { name: 'Dashboard' }).waitFor();
await expect(page.getByTestId('route-transition')).toBeHidden();

The second assertion assumes your app exposes a test-friendly signal, which is often better than inferring readiness from animation timing.

Make locators specific to the final state

If the transition duplicates visible text, use locators that uniquely identify the new route. Prefer role plus accessible name, or a data-testid on a stable container that exists only in the final route.

typescript

await expect(page.getByTestId('dashboard-page')).toBeVisible();
await page.getByTestId('settings-save').click();

The tradeoff is straightforward: data-testid is less user-facing than role-based locators, but for route transition tests it can be the more stable choice when the UI intentionally duplicates visible labels during animation.

Avoid element handles for anything that can re-render

Use locators, not handles, across navigations and route transitions. Locators re-resolve against the current DOM, which is exactly what you want when the framework is swapping route nodes.

Good:

typescript

await page.getByRole('button', { name: 'Continue' }).click();

Riskier:

typescript

const continueButton = await page.getByRole('button', { name: 'Continue' }).elementHandle();
await continueButton?.click();

Wait for the route to settle, not just the URL to change

URL change is necessary, but not always sufficient. If the app updates history immediately and renders the new screen asynchronously, then the right wait condition is often a combination of URL and content.

typescript

await expect(page).toHaveURL(/\/reports/);
await expect(page.getByTestId('reports-page')).toBeVisible();

For apps that use animated shell components, also wait for the outgoing page to disappear:

typescript

await expect(page.getByTestId('home-page')).toBeHidden();
await expect(page.getByTestId('reports-page')).toBeVisible();

Reduce transition complexity in test mode

A practical stabilization strategy is to disable or shorten transitions when running automated tests. That can be done with a prefers-reduced-motion CSS branch or a dedicated test configuration.

@media (prefers-reduced-motion: reduce) {
  * {
    animation-duration: 0.01ms !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}

If your app uses CSS View Transitions, you can also gate them behind a runtime flag in test environments. The goal is not to avoid testing the route change, it is to remove animation timing as a variable when the animation itself is not under test.

Disabling motion in test runs is often a valid tradeoff when the purpose of the test is route correctness, not animation fidelity.

Keep one or two dedicated animation tests, not every test

You do not need every functional test to validate animation behavior. In most teams, the higher-value pattern is:

  • broad functional tests with motion minimized or disabled
  • a small number of targeted checks that verify the transition exists and does not break layout, focus, or accessibility

That keeps the main suite focused on route correctness, while reserving motion-specific assertions for the handful of cases where animation itself matters.

A practical Playwright pattern for animated routes

A robust test usually follows a sequence like this:

  1. trigger the navigation
  2. wait for the old screen to leave the DOM or become hidden
  3. wait for the new route container to appear
  4. assert on a stable element in the final state

typescript

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

await expect(page.getByTestId(‘home-page’)).toBeHidden();

await expect(page.getByTestId('billing-page')).toBeVisible();
await expect(page.getByRole('heading', { name: 'Billing' })).toBeVisible();

If your app uses a transition overlay, it may also be worth asserting that the overlay is gone before proceeding. The important point is to test the transition contract explicitly instead of assuming the browser has finished animating when the click returns.

What to change in the app, not just the test

Sometimes the test is exposing a genuine app design problem. A few implementation choices make automation much harder:

  • duplicate interactive controls in both old and new route trees
  • overlays that intercept clicks longer than necessary
  • route shells that keep old content mounted without clear test hooks
  • motion states that do not preserve focus in a predictable way
  • hidden content that remains accessible to locators when it should not

The right fix may be in the frontend code. For example, if a route transition duplicates a submit button, move the button into the persistent shell or add a distinct test identifier to the destination page. If an overlay is purely decorative, ensure it does not intercept pointer events after the transition should have completed.

Accessibility also matters here. If the app is using aria-hidden, focus management, and clear route landmarks correctly, Playwright tests are easier to write because the accessibility tree reflects the intended state more accurately than the raw DOM does.

CI behavior makes this worse, not better

These failures often appear more in CI than locally because CI is slower, browser timing varies, and the test runner may be sharing CPU with other jobs. A transition that takes one or two frames longer in CI can be enough to expose an overlap window.

That is why browser automation problems should be treated as infrastructure and observability problems, not just test syntax problems. Capture enough evidence in CI to distinguish between:

  • a locator mismatch
  • a real application bug
  • a timing issue caused by motion
  • a cross-browser difference in animation behavior

If the failure rate is intermittent, record trace artifacts and videos on retry or failure. A flaky route transition often becomes obvious once you can see the state sequence instead of guessing from the error message.

A short checklist for teams

When Playwright tests fail with view transitions, use this order of operations:

  • confirm whether the app uses CSS view transitions, route exit animations, or both
  • inspect trace output to see duplicate nodes or overlays
  • replace element handles with locators where needed
  • wait for the final route container, not only the URL
  • prefer stable page landmarks or test IDs for transitional screens
  • disable motion in most test runs, keep a small number of animation-specific checks
  • reduce duplicate interactive elements during route overlap
  • make sure hidden transition layers are not still intercepting clicks

When to keep testing the animation

Not every motion should be stripped out of automation. If a route transition carries meaningful product behavior, test it. Examples include:

  • transitions that preserve focus order across screens
  • animated loading states that must not block interaction too long
  • route changes that affect accessibility announcements
  • motion that drives actual UI state, such as expanding a nested shell or dismissing a modal route

In those cases, assert the business rule or accessibility effect, not the exact frame timing. The browser may render frames slightly differently across environments, but the user-facing contract should still be testable.

The practical takeaway

Playwright tests fail with view transitions because the app has created a valid intermediate state that the test did not account for. The failure is usually about timing, duplication, or actionability, not a broken browser.

The most reliable fix is to make route readiness explicit, use locators that target the final state, and remove animation timing from the majority of functional tests. Keep a smaller set of checks that cover the motion itself when it matters. That approach gives you faster debugging, less frontend flakiness, and a suite that reflects how the app behaves in real browsers instead of how you wish it behaved.

For teams building on Playwright, the official documentation is a good starting point for understanding its auto-waiting model and locator behavior, especially when paired with real trace inspection and a clear route readiness signal in the app.

If you want a broader framing on why these failures happen at all, it helps to think of them as a Test automation problem inside a larger Software testing system. Frontend animation changes the observable state machine, and the test runner must sync to that machine rather than the idealized user story.

That is the real lesson behind most frontend transition flakiness: the UI is not just changing, it is changing over time, and the test has to choose the right moment to ask its question.