How to fix Playwright "locator.click: Timeout ... element is not visible"?
PlaywrightThis timeout fires when the element exists in the DOM but Playwright cannot confirm it is visible, enabled, and not obscured within the timeout window. The distinction from a regular timeout is important: the element is found, but something is blocking the action — a loading overlay, a CSS display: none, a zero-opacity animation, or another element positioned on top. Knowing how to wait for an element to be visible and in an actionable state is one of the most common Playwright challenges.
Common mistake
test('opens settings panel', async ({ page }) => {
await page.goto('https://app.example.com/dashboard');
// Panel exists in DOM but is hidden via CSS — click times out
await page.locator('#settings-panel').click();
});
Also common after a navigation or dynamic render when the trigger element hasn't appeared yet:
// Clicking before the menu has rendered — "element is not visible" until menu opens
await page.locator('.dropdown-item[data-action="delete"]').click();
The fix
Wait for visibility explicitly before acting:
import { test, expect } from '@playwright/test';
test('opens and uses settings panel', async ({ page }) => {
await page.goto('https://app.example.com/dashboard');
// First, trigger whatever reveals the element
await page.getByRole('button', { name: 'Settings' }).click();
// Wait for element to be visible before clicking inside it
const panel = page.getByRole('dialog', { name: 'Settings' });
await expect(panel).toBeVisible({ timeout: 10000 });
await panel.getByRole('button', { name: 'Save changes' }).click();
await expect(page.getByRole('alert')).toContainText('Settings saved');
});
When an overlay or spinner is blocking the target:
test('submits form after loading', async ({ page }) => {
await page.goto('https://app.example.com/checkout');
// Wait for the loading overlay to disappear before interacting
await expect(page.getByTestId('loading-overlay')).toBeHidden({ timeout: 15000 });
const submitBtn = page.getByRole('button', { name: 'Complete order' });
await expect(submitBtn).toBeEnabled();
await submitBtn.click();
});
For elements that animate in:
// The locator will auto-retry until opacity/transform settles to visible state
await expect(page.getByRole('tooltip')).toBeVisible();
await page.getByRole('tooltip').getByRole('link', { name: 'Learn more' }).click();
Why it works
Playwright's actionability checks run before every action and include: is the element in the DOM, is it visible (non-zero size and not display:none/visibility:hidden), is it enabled, and is it not covered by another element at the click point. The expect(locator).toBeVisible() assertion uses the same retry loop but exposes the wait explicitly, giving you control over timeout per-step. Waiting for an overlay to be hidden before acting removes the coverage check failure that causes the "element is not visible" timeout on the underlying target.
Tips
- Use locator.waitFor({ state: 'visible' }) as a programmatic alternative to expect().toBeVisible() when you want to wait without asserting in the test report.
- If the element is visible in headed mode but not in headless, the issue is often font rendering or animation timing — add a fixed viewport in config and check for CSS prefers-reduced-motion media query support.
- For elements that are visible but still fail the click actionability check, use locator.click({ force: true }) only as a last resort — it bypasses coverage checks and can mask real UI bugs.
- For the general timeout case where the element is never found at all (not just not visible), see how to fix Playwright TimeoutError.