ScreenshotNeo

BlogHow-to

How to Check If an Element Is Visible in Playwright

Use Playwright’s retrying toBeVisible() assertion for tests, isVisible() for an immediate boolean, and waitFor() for procedural waits.

By the ScreenshotNeo team1 October 20267 min read

Use await expect(locator).toBeVisible() when a Playwright test must verify that an element appears. Use await locator.isVisible() only when you need the element’s current visibility as a boolean. For procedural code that must wait without an assertion, use await locator.waitFor({ state: 'visible' }).

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

test('shows the confirmation message', async ({ page }) => {
  await page.getByRole('button', { name: 'Submit' }).click();
  await expect(page.getByText('Your order was sent')).toBeVisible();
});

This guide explains which API to choose, what Playwright means by “visible,” how viewport checks differ, and how to troubleshoot flaky visibility checks.

1. Choose the right visibility API

Need Use Behavior
Assert that an element becomes visible in a test await expect(locator).toBeVisible() Retries until the condition passes or the assertion timeout expires.
Read visibility immediately await locator.isVisible() Returns a boolean for the current state and does not wait for a later transition.
Wait procedurally for visibility await locator.waitFor({ state: 'visible' }) Resolves when the locator is visible or throws after the timeout.
Select matches that are visible locator.visible() Creates a locator filtered to visible matches (available in Playwright v1.63 and later).
Check whether an element intersects the viewport await expect(locator).toBeInViewport() Tests viewport intersection, which is separate from Playwright visibility.

For test expectations, prefer the web-first assertion. It automatically re-evaluates the locator while the page changes, so it handles asynchronous rendering better than reading a boolean once.

2. A complete Playwright Test example

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

test('shows a success message after submitting', async ({ page }) => {
  await page.goto('https://example.com/checkout');

  const submit = page.getByRole('button', { name: 'Submit' });
  await expect(submit).toBeVisible();
  await submit.click();

  const confirmation = page.getByText('Your order was sent');
  await expect(confirmation).toBeVisible();
});

Use a user-facing locator whenever possible. getByRole() is a strong choice for buttons, links, checkboxes and other controls; getByText() is useful for visible messages. Locators resolve against the current DOM when they are used, which helps when a framework re-renders the page. Narrow an ambiguous locator by role, accessible name, text or a containing region.

Checking a form control

await expect(page.getByRole('checkbox', { name: 'Subscribe to updates' })).toBeVisible();
await expect(page.getByLabel('Email address')).toBeVisible();

Checking a dialog that appears asynchronously

await page.getByRole('button', { name: 'Open settings' }).click();
await expect(page.getByRole('dialog', { name: 'Settings' })).toBeVisible();

Checking a visible element inside a region

const results = page.getByRole('region', { name: 'Search results' });
await expect(results.getByRole('heading', { name: 'Results' })).toBeVisible();

3. What Playwright means by visible

Playwright considers an element visible when it has a non-empty bounding box and its computed visibility is not hidden. An element with display: none, no layout box, or a zero-size box is therefore not visible under this definition. See the official locator assertion documentation.

Visibility does not guarantee that the element is unobstructed, enabled, clickable, or currently inside the viewport. Treat those as separate questions:

await expect(locator).toBeVisible();
await expect(locator).toBeEnabled();
await expect(locator).toBeInViewport();

4. When to use isVisible()

isVisible() is appropriate when a branch needs the current state immediately:

const banner = page.getByRole('alert');
const visible = await banner.isVisible();

if (visible) {
  await page.getByRole('button', { name: 'Dismiss' }).click();
}

It does not wait for the element to appear. This common pattern is unreliable for asynchronous UI:

// Checks once and can fail before the UI has rendered.
expect(await page.getByText('Saved').isVisible()).toBe(true);

Replace it with a retrying assertion:

await expect(page.getByText('Saved')).toBeVisible();

If you genuinely need a boolean after waiting, wait first and then read it:

const saved = page.getByText('Saved');
await saved.waitFor({ state: 'visible' });
const isVisible = await saved.isVisible();

5. Procedural waits with waitFor()

Use waitFor() when a helper or setup routine needs to pause until a state is reached but should not make an assertion itself.

const editor = page.locator('[contenteditable="true"]');
await editor.waitFor({ state: 'visible', timeout: 10_000 });
await editor.fill('Draft text');

The supported states are attached, detached, visible and hidden. Prefer locator-based waits over older ElementHandle.waitForSelector() examples.

6. Visibility versus being on screen

An element can satisfy Playwright’s visibility rules while it is below the fold or otherwise outside the viewport. Use toBeInViewport() when the requirement is visual intersection with the current viewport.

const pricing = page.getByRole('heading', { name: 'Pricing' });
await expect(pricing).toBeVisible();
await expect(pricing).toBeInViewport();

You can require a minimum intersection ratio:

await expect(pricing).toBeInViewport({ ratio: 0.5 });

For an interaction, let the action perform its normal scrolling and actionability checks:

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

7. Handling multiple matching elements

Visibility should not be used as a substitute for identifying the intended element. If several elements match, make the locator more specific.

// Better: identify the dialog by its accessible name.
const dialog = page.getByRole('dialog', { name: 'Delete account' });
await expect(dialog).toBeVisible();

// If a list intentionally contains several matching rows:
const visibleRows = page.getByRole('row').visible();
await expect(visibleRows).toHaveCount(3);

Use first() or nth() only when the page contract makes that position meaningful. Otherwise, add a role, name, text or container constraint.

8. Timeouts and configuration

Assertion timeouts control how long toBeVisible() retries. Configure a project-wide default in playwright.config.ts:

import { defineConfig } from '@playwright/test';

export default defineConfig({
  expect: {
    timeout: 10_000,
  },
  use: {
    actionTimeout: 15_000,
    navigationTimeout: 30_000,
  },
});

Override a single assertion when one operation has a known longer startup:

await expect(page.getByText('Report ready')).toBeVisible({ timeout: 30_000 });

Use the smallest timeout that matches the application’s real behavior. Very large values hide regressions and slow failures; very small values create flakes on slower CI workers.

9. Common errors and fixes

Symptom Likely cause Fix
expect(locator).toBeVisible() times out The element never appears, the locator is wrong, or rendering is slower than the timeout. Inspect the locator, wait for the triggering action, and adjust the assertion timeout only when the delay is expected.
isVisible() returns false immediately The method is a snapshot and does not wait. Use await expect(locator).toBeVisible() or locator.waitFor({ state: 'visible' }).
Visibility passes but a click fails The element may be covered, disabled, outside the viewport, or still changing. Check toBeEnabled(), toBeInViewport(), and the action’s error details; fix the overlay or wait for the state that makes the control actionable.
The wrong matching element is selected The locator matches several nodes. Use an accessible name, a parent region, a unique test id, or a more specific selector.
A hidden duplicate causes confusion Responsive layouts or menus often keep duplicate nodes in the DOM. Use a more specific locator or locator.visible() where selecting visible matches is the actual requirement.
Test is flaky only in CI Different viewport, fonts, network speed, animations or data timing. Use deterministic test data, disable nonessential animations, wait on a user-visible state, and collect a trace on failure.

10. Debugging a failed visibility check

  1. Confirm the page and state with await expect(page).toHaveURL(...) or a distinctive heading.
  2. Log or inspect the locator count with await locator.count().
  3. Check whether the element is attached, hidden by CSS, or replaced during a render.
  4. Use Playwright tracing, screenshots and the HTML inspector to see the failing state.
  5. Replace brittle selectors with role and accessible-name locators.
const target = page.getByRole('button', { name: 'Submit' });
console.log('matches:', await target.count());
console.log('visible now:', await target.isVisible());
await expect(target).toBeVisible();

11. Performance and reliability guidance

  • Prefer one precise locator over broad CSS queries followed by manual filtering.
  • Use web-first assertions so polling follows the page’s actual state changes.
  • Avoid fixed sleeps such as page.waitForTimeout(); they add delay without proving that the element is ready.
  • Keep assertions close to the action that causes the UI transition, which makes failures easier to diagnose.
  • Use stable accessible names or explicit test ids when copy changes frequently.
  • Run the same viewport and device settings in local and CI environments when layout affects visibility.

12. Capture the result for review

When a visibility failure needs a visual artifact, save a Playwright screenshot from the failing state:

await page.screenshot({ path: 'artifacts/visibility-failure.png', fullPage: true });

Or skip the browser setup

If your goal is a clean screenshot of a page or element rather than an end-to-end assertion, ScreenshotNeo provides a single request. Its API can remove cookie banners, newsletter popups and chat widgets before capture; bot checks, blank pages, timeouts, failed loads and cache hits are not billed. An MCP server provides take_screenshot, get_page_info and capture_pdf tools for Claude, Cursor and other MCP clients. The Free plan includes 1,000 screenshots a month with no card, and paid plans start at $5 for 3,000 shots.

See the ScreenshotNeo API documentation for options such as element selectors, full-page capture, custom CSS and JavaScript, device presets, waits, request blocking and signed links.

cURL

curl -G "https://api.screenshotneo.com/v1/shot" \
  -d access_key=YOUR_API_KEY \
  --data-urlencode url=https://example.com \
  -o shot.webp

Python

import requests

r = requests.get(
    "https://api.screenshotneo.com/v1/shot",
    params={"access_key": "YOUR_API_KEY", "url": "https://example.com"},
    timeout=90,
)
r.raise_for_status()
open("shot.webp", "wb").write(r.content)

Node.js

const q = new URLSearchParams({
  access_key: 'YOUR_API_KEY',
  url: 'https://example.com'
});
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot failed: ${res.status}`);
const image = Buffer.from(await res.arrayBuffer());
await import('node:fs/promises').then(fs => fs.writeFile('shot.webp', image));

Create a free ScreenshotNeo account to get 1,000 screenshots each month with no card.

FAQ

Should I use toBeVisible() or isVisible()?

Use toBeVisible() for a test expectation because it retries. Use isVisible() for an immediate conditional check.

Does isVisible() wait for an element?

No. It returns the current result immediately. Use a web-first assertion or waitFor() when the element may appear later.

Does visible mean the user can see it on screen?

Not necessarily. Playwright visibility is based on layout size and computed visibility. Use toBeInViewport() for viewport intersection.

What should I use instead of ElementHandle.waitForSelector()?

Use locators with expect(locator).toBeVisible() in tests or locator.waitFor({ state: 'visible' }) in procedural code.