In Playwright, what must be true of an element before locator.click() actually clicks it?
answer
- The action waits before it acts
- Four conditions, re-checked every attempt
- Layout box, animation frames, hit test
- A disabled ancestor counts too
- The call log names the failed one
basics
~20 sPlaywright retries a set of actionability checks until they all pass or the call times out: the element must be visible, stable in position, able to receive pointer events at the click point, and enabled.
solid answer
~50 sEvery Playwright action is gated on actionability checks that the runner re-evaluates until they pass or the call times out. For a click the element must be **visible** (a non-empty bounding box, and computed `visibility` not `hidden`), **stable** (its bounding box unchanged across two consecutive animation frames, so it is not mid-animation), **receiving events** (a hit test at the click point resolves to that element or a descendant, not to an overlay), and **enabled** (not `disabled`, including a `disabled` ancestor `fieldset`). Playwright also scrolls the element into view before hit-testing. Not every action needs all four: `fill` wants visible, enabled and editable but skips stability and the hit test, and `press` runs no checks at all. Failing checks are retried, so the click fires as soon as the app settles; if it never does, the timeout error's call log names the check that never passed.
code
typescript · 9 linesimport { test, expect } from '@playwright/test';
test('assign an issue from the board', async ({ page }) => {
const card = page.getByRole('listitem', { name: 'ISSUE-412' });
// click() waits for the Assign button to be visible, stable,
// hit-testable and enabled before it dispatches anything.
await card.getByRole('button', { name: 'Assign' }).click({ timeout: 5_000 });
await expect(page.getByText('Assigned to you')).toBeVisible();
});go deeper
Recall that Playwright waits on its own before a click: the element has to be visible, stable, hit-testable and enabled. Do not reach for a sleep to make a click work.
Explain each check in DOM terms - bounding box and computed visibility, two identical animation frames, a hit test at the click point, the disabled state - and say which actions skip which.
Show that you read the call log to name the check that never passed, and that you fix the app or retarget the locator rather than opting out of the gate.
Own the standard for how the team answers a failed check: read it and fix the cause, so the gate stays a signal about the product rather than something the suite routinely bypasses.
## What "actionability" means in Playwright Playwright does not act on whatever the selector matched at the instant you called it. Every action -- `locator.click()`, `locator.check()`, `locator.hover()`, `locator.fill()` -- first runs a set of **actionability checks** against the element the locator resolves to, and it re-runs the whole cycle until every required check passes or the call's timeout expires. That loop is what people mean by Playwright's auto-waiting: there is no separate wait step because the gate is built into the action itself. ## The five checks - **Visible** -- the element has a non-empty bounding box and its computed `visibility` is not `hidden`. An element with `display: none`, a zero-height container, or one that has not been rendered yet fails here. - **Visible, the surprising part** -- an element with `opacity: 0` **passes**. It still occupies layout, so Playwright counts it as visible and will happily click something the user cannot see. - **Stable** -- the element's bounding box is identical across two consecutive animation frames. A card sliding into a board column, a modal easing open, or a list reflowing after a fetch is unstable until the movement stops. - **Receives events** -- Playwright hit-tests the point it is about to act on. The node found at that point must be the target element or one of its descendants. A cookie banner, a toast, a modal backdrop or a stray full-screen `div` fails this check, and the error names the node that got in the way. - **Enabled** -- the element is not disabled. For form controls that includes the disabled state inherited from an ancestor `<fieldset>`. - **Editable** -- the element is enabled and not `readonly`. Only the value-writing actions ask for it. ## Which action requires which check | Action | Visible | Stable | Receives events | Enabled | Editable | |---|---|---|---|---|---| | `click`, `dblclick`, `tap`, `check`, `uncheck` | yes | yes | yes | yes | -- | | `hover`, `dragTo` | yes | yes | yes | -- | -- | | `selectOption` | yes | yes | -- | yes | -- | | `fill`, `clear` | yes | -- | -- | yes | yes | | `screenshot` | yes | yes | -- | -- | -- | | `focus`, `press`, `dispatchEvent`, `setInputFiles` | -- | -- | -- | -- | -- | Two things fall out of that table. First, the gate is **per action**, so "Playwright waits for the element" is too coarse an answer in an interview -- the interesting follow-up is always which checks a given call skips. Second, a handful of calls require nothing at all, which is exactly why they can drive an element a real user could never reach. ## What one attempt actually does 1. Re-resolve the locator against the live DOM. 2. Run the checks this action requires against the resolved element. 3. Scroll the element into view if it is not already there. 4. Compute the action point and, for pointer actions, hit-test it. 5. Perform the action -- or start another attempt if a check failed. Because step 1 runs every time, an issue card that re-renders between attempts is simply found again. Because steps 2-4 run every time, a condition that turns true halfway through the window is picked up on the next pass, and the action fires as soon as the app settles rather than after a fixed delay. ## When the window runs out The failure is a `TimeoutError` whose message names the call and the budget, followed by a call log that replays the attempt: ``` TimeoutError: locator.click: Timeout 5000ms exceeded. Call log: - waiting for getByRole('button', { name: 'Assign' }) - locator resolved to <button disabled>Assign</button> - attempting click action - waiting for element to be visible, enabled and stable - element is not enabled - retrying click action, attempt #2 ``` The last line the log reached is the check that never passed. That is the whole diagnostic value of the error, and it is why raising the timeout before reading the log is the wrong first move. ## What the gate does and does not promise - It promises the element was **interactive** at the moment of the action -- laid out, still, reachable and not disabled. - It does not promise the element is the **right** one: a locator that matches a stale issue card passes every check. - It does not promise the action **worked**; the event was dispatched, and whether the app handled it is what the next assertion is for. - Passing `force: true` removes the promise while leaving the dispatch in place, and `trial: true` keeps the promise while removing the dispatch.
- Does an element with `opacity: 0` pass Playwright's visibility check?Yes. Playwright calls an element visible when it has a non-empty bounding box and its computed `visibility` is not `hidden`, and a zero-opacity element still occupies layout. So the click proceeds on something the user cannot see. If it should count as hidden, the app needs `visibility: hidden`, `display: none`, or to not render it.
- Which clock bounds the retry loop when you pass no timeout to the action?In `@playwright/test` the per-action `timeout` falls back to the `actionTimeout` setting, which is `0` -- unbounded -- out of the box, so the checks retry until the test's own timeout ends the attempt. Setting `actionTimeout` in the config, or passing `timeout` on the call, gives you a shorter and far more legible failure.
The action behaves like a driver at a level crossing rather than one on a stopwatch: it does not move after a set number of seconds, it moves when the barrier is up, the track is clear and the light is green.
saying these in an interview costs you the question
- Says Playwright clicks immediately so you must add a sleep
- Thinks opacity zero fails the visibility check
- Believes every action runs the same set of checks
- Claims the checks run once instead of being retried
- Ignores disabled inherited from an ancestor fieldset