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');
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
getAnimationsto 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.
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 |
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.