Article · Root Causes of JavaScript Test Flakiness

Waiting for CSS Transitions Deterministically

Sometimes the animation is the behaviour: a drawer must open, a toast must appear and then leave, an accordion must expand before its contents are reachable. Those tests cannot switch motion off, so they need the opposite skill — waiting for a transition to finish without guessing its duration. This guide complements the motion-off approach in Timer & Animation Flakiness with conditions that observe the transition itself rather than counting milliseconds.

12 sections URL: /root-causes-of-javascript-test-flakiness/timer-and-animation-flakiness/waiting-for-css-transitions-deterministically/
The lifecycle of a transition and where tests go wrong A transition has a delay, a running phase and a settled end state; asserting during the delay or the running phase produces intermittent results. delay running — box moves every frame settled element already present visible but moving safe to click and assert "visible" is true across all three phases — it is not the condition the test needs
Visibility becomes true at the start of the transition; the test needs the end of it.

Root cause #

A CSS transition changes computed style over time, and every intermediate frame is a legitimate state of the DOM. The element exists throughout, is visible for most of it, and is hit-testable from the first frame — so the three conditions tests reach for by default are all satisfied long before the element arrives where it is going. The result is a test that passes on a fast machine, where the transition completes between two automation steps, and fails on a loaded runner where it does not.

Duration is the wrong thing to encode for a second reason: it is not one number. The effective settle time is transition-delay + transition-duration, it differs per property, a design-system update changes it without touching any test, and prefers-reduced-motion can zero it entirely. A test that sleeps for 300 ms because the drawer takes 300 ms is coupled to a token in a stylesheet nobody thinks of as a test dependency.

The reliable signal is the browser’s own: the transitionend event, the animation objects the document exposes, or the settled value of the property being animated. All three describe the end state directly, which makes them immune to both machine speed and stylesheet changes. The subtlety is that transitionend fires per property — a transition on transform and opacity fires twice — and does not fire at all if the transition is interrupted or never started, which is why a raw event listener is a fragile foundation on its own.

Step-by-step fix #

1. Assert the end state, not the motion #

The simplest deterministic wait is a retrying assertion on the property value the transition produces. It needs no knowledge of duration and reads as the behaviour the user cares about.

// Trade-off: asserting a computed value couples the test to a style detail;
// prefer a semantic state attribute where the application exposes one.
const drawer = page.getByRole('dialog', { name: 'Filters' });
await expect(drawer).toBeVisible();
await expect(drawer).toHaveCSS('opacity', '1');       // retries until settled
await expect(drawer).toHaveCSS('transform', 'none');  // finished sliding

2. Prefer a state attribute the application controls #

The robust version is for the component to announce its own state, so the test waits on a contract rather than on a style. This is the same idea as making loading states observable, and it pays off in the same way.

// In the component — one attribute, updated on transition end.
// Trade-off: a small amount of component code exists purely to be observable,
// and it replaces every duration-coupled wait in the suite.
<aside data-state={isOpen ? (isAnimating ? 'opening' : 'open') : 'closed'}>
// In the test — no durations, no computed styles.
await expect(page.getByTestId('filter-drawer')).toHaveAttribute('data-state', 'open');
Three ways to wait, ranked by coupling A fixed sleep couples to duration, a computed-style assertion couples to the stylesheet, and a state attribute couples to a contract. sleep 300 mscouples to a token toHaveCSS(opacity, 1)couples to the stylesheet data-state="open"couples to a contract breaks when a designer changes a duration breaks only when behaviour changes each step right removes one thing that can change without the behaviour changing
The ranking is about coupling, not about correctness: all three can pass today, and only the last survives a design-token change.

3. Wait for the document’s animations to settle #

When you cannot change the component, ask the browser directly. document.getAnimations() returns every running animation and transition, each with a promise that resolves when it finishes.

// Trade-off: this waits for ALL animations, so a permanent looping spinner
// elsewhere on the page will hang it — scope to the element where possible.
await page.locator('[data-testid="filter-drawer"]').evaluate((el) =>
  Promise.all(el.getAnimations({ subtree: true }).map((a) => a.finished))
);

Scoping to a subtree is what makes this practical: page-level waits are trivially broken by any decorative infinite animation, which is common in loading skeletons and brand flourishes.

4. Handle interrupted transitions #

A drawer that is closed while it is still opening never fires the transitionend for the opening transition — the transition is replaced, not completed. Any wait built on that event alone will time out on the fast-interaction path, which is exactly the path a test exercises when it clicks quickly.

// Trade-off: racing the event against a settled-state check is more code, and
// it is what makes the wait correct when a user interrupts the animation.
await Promise.race([
  drawer.evaluate((el) => new Promise((r) => el.addEventListener('transitionend', r, { once: true }))),
  expect(drawer).toHaveAttribute('data-state', /open|closed/).then(() => true),
]);

Preferring the state attribute avoids this class of problem entirely, which is the strongest argument for asking the application to expose one.

5. Test the exit transition, which is where clicks go astray #

An element animating out is still in the DOM and still clickable. The common bug is a test that closes a dialog and immediately clicks something underneath, hitting the dialog’s backdrop instead. Assert removal, not just invisibility.

// Trade-off: toBeHidden() passes as soon as opacity hits zero, while the node
// may still cover the click target — assert detachment when it matters.
await page.getByRole('button', { name: 'Close' }).click();
await expect(page.getByRole('dialog')).toHaveCount(0);   // actually gone
await page.getByRole('button', { name: 'Underlying action' }).click();

6. Decide per spec whether motion should be on at all #

Most specs should run with motion disabled, as described in Disabling CSS Animations in E2E Tests — the transition is incidental to what they verify. Reserve the techniques here for the specs where the transition is the feature: a drawer’s open and close behaviour, a toast’s auto-dismiss, a stepper’s forward and back movement. Keeping that set small is what keeps it maintainable, because these are inherently the most timing-sensitive tests in the suite.

Pitfalls #

  • Sleeping for the transition duration. The duration is a design token that changes without notice. Mitigation: wait on the end state.
  • Treating toBeVisible() as settled. Visibility is true from the first frame of an entrance. Mitigation: assert the final computed value or a state attribute.
  • Listening for a single transitionend. It fires per property and not at all when interrupted. Mitigation: prefer state attributes, or race the event with a state check.
  • Waiting for all animations on the page. One infinite decorative animation hangs the wait forever. Mitigation: scope getAnimations to the subtree under test.
  • Clicking through a closing dialog. The node is still present during the exit transition. Mitigation: assert detachment before interacting with what is underneath.
  • Reading a computed style once. A single read is a snapshot of a moving value. Mitigation: use a retrying assertion, which re-reads until it holds.
Entrance and exit need different assertions An entrance ends with a settled state attribute; an exit ends with the node removed from the DOM. entrance state = "open"or transform = none safe to click exit toHaveCount(0)not merely hidden backdrop gone
Hidden and removed are different states, and the difference is exactly what a click lands on.

Reliability targets #

Metric Target Notes
Duration-coupled waits in motion specs 0 No sleeps tied to a design token
Motion-aware specs in the suite 3–10 Entrance, exit, auto-dismiss
Components exposing a state attribute 100% of animated ones Contract, not computed style
Failures from clicking a closing overlay 0 Detachment asserted
Motion spec pass rate ≥ 99.5% Inherently the most timing-sensitive tests
Transition-waiting scorecard Targets for duration coupling, state attributes, overlay click failures and motion spec pass rate. 0duration-coupled waits 100%state attributes 0overlay misclicks 99.5%motion spec pass rate
A small, well-instrumented set of motion specs is far more valuable than a large set that sleeps.

Frequently Asked Questions #

Q: Why does toBeVisible() pass before the element has finished moving? A: Because visibility is defined by layout and computed style, not by stillness: an element with a non-empty bounding box and a non-hidden visibility is visible from the first frame of its entrance. It is the correct condition for “is this on screen” and the wrong one for “has this arrived”.

Q: getAnimations() never resolves. What is holding it? A: Almost always an infinite animation elsewhere on the page — a loading skeleton, a pulsing badge, a decorative gradient. Scope the call to the element’s subtree, or filter the returned animations by their effect target before awaiting them.

Q: Should the component really expose test-facing state? A: It is not test-facing state so much as observable state, and it usually improves the component. The same attribute that lets a test wait for “open” lets assistive technology and analytics observe the same thing, and it documents a state machine that was previously implicit. Treat it as part of the component’s contract rather than as a testing hook.

Q: The drawer test passes locally and fails on CI roughly one run in twenty. Where do I start? A: Check what the test waits for before it interacts. A one-in-twenty failure on a slower machine is the signature of a wait that is satisfied during the transition rather than after it — usually toBeVisible() followed immediately by a click. Replace it with an assertion on the settled state and the failure rate normally goes to zero without touching timeouts. If it persists, capture a trace and look at whether the element’s bounding box was still changing at the moment of the action; that distinguishes a transition problem from a genuine race in the application’s state updates.

Q: Can I assert on the transition duration itself to catch design regressions? A: You can, and it belongs in a unit or visual test rather than in a flow test. Reading transitionDuration from computed style in a component test gives a stable, fast check that a token has not changed, without making every end-to-end spec depend on the value.

Q: How do I test a toast that dismisses itself after four seconds? A: Control the clock rather than waiting four seconds per run. Freeze time, assert the toast is present, advance the clock past the dismiss interval, then assert it is detached — the same technique as Faking Timers with Jest and cy.clock. Waiting in real time makes the test both slow and timing-dependent.