Playwright & Web Automation Hub

Playwright architecture, selector reliability, and advanced interaction patterns.

getByRole & Accessibility Selectors

Single-page applications rewrite their DOM constantly: class names are hashed at build time, wrapper <div>s appear and vanish between framework versions, and component libraries restructure markup on every minor release. A test suite pinned to that structure breaks on changes that never altered what a user can see or do. getByRole() and the other accessibility selectors sidestep the problem by querying the browser's computed accessibility tree — the same model a screen reader consumes — rather than raw HTML. The role and accessible name of a control are part of its contract with assistive technology, so they stay stable even as the implementation churns. This guide is part of the wider Reliable Selector Strategies for Playwright standards, and it explains how role resolution works, how to filter precisely by name and state, how to synchronize with widgets that mutate ARIA after mount, and how to fold accessibility validation into your pipeline.

How getByRole resolves a control through the accessibility tree A button element in the DOM is mapped to a node in the accessibility tree with a computed role and accessible name, which getByRole matches, while a CSS class selector points straight at the volatile markup. DOM markup <button class="btn-x9f"> Save </button> Accessibility tree role: button name: "Save" state: enabled getByRole 'button', { name: 'Save' } stable match CSS selector .btn-x9f breaks on rebuild Role + name come from the contract a screen reader reads, not the generated class names.
getByRole matches against the computed role and accessible name in the accessibility tree, which survives refactors; a hashed CSS class points straight at volatile markup and breaks on the next build.

How role resolution works

Playwright does not parse raw HTML to find a role-based target. It reads the accessibility tree the browser already computes for assistive technology, where every rendered element carries a role, an accessible name, and a set of states and properties. A native <button> resolves to the button role implicitly; an <a href> resolves to link; an <input type="checkbox"> resolves to checkbox with a checked state. Elements without a native role can declare one with role="...", and Playwright honors the same ARIA mapping the browser uses.

Because the role and name are derived from semantics rather than structure, a refactor that swaps a <div onclick> for a real <button>, or renames a CSS module, leaves the locator untouched. This is the property that makes accessibility selectors the default recommendation over the structural approach covered in CSS & XPath Best Practices: the structural path encodes how the markup is built today, while the role path encodes what the control is. The architectural argument for preferring roles in component-driven codebases is laid out in full in Why getByRole Beats CSS Selectors in Modern Apps.

The accessible name is computed by the browser's accessible name algorithm, which considers, in priority order, aria-labelledby, aria-label, associated <label> elements, the element's own text content, and finally attributes like title. When getByRole('button', { name: 'Save' }) matches, it is matching against that computed name — not the literal text node — so a button labeled through aria-label resolves exactly as one labeled by visible text.

The ARIA role catalog for test authors

You do not need the full ARIA specification to write durable locators, but a working command of the roles you will actually target makes the difference between confident queries and trial-and-error. The roles below cover the overwhelming majority of interactive and structural targets in a typical web application.

Command and input roles. button covers native <button> elements and anything carrying role="button", and it is the workhorse of interaction tests. link maps to <a href>; an anchor without an href is not a link and exposes no link role, which is a common surprise when a framework renders navigation as clickable <a> elements without real URLs. textbox covers single-line <input> types (text, email, search, tel, url) and <textarea>. checkbox and radio carry a checked state; radio controls additionally participate in a named group. combobox represents an editable or selectable control that owns a popup, while listbox and its child option roles model the popup itself. slider, spinbutton, and switch round out the form controls you will meet in design systems.

Structural and landmark roles. heading exposes a level (1 through 6) that mirrors the document outline. list and listitem model <ul>/<ol> and their <li> children. Tables expose a rich set: table (or grid for interactive data grids), row, cell (or gridcell), columnheader, and rowheader. Landmark roles — banner, navigation, main, complementary, contentinfo, and the generic region (which requires an accessible name to be exposed) — give you stable containers to scope inside, which is invaluable when the same control appears in a header and a footer.

Disclosure, dialog, and status roles. dialog and alertdialog model modals. menu, menubar, and menuitem (plus menuitemcheckbox and menuitemradio) cover application menus. tab, tablist, and tabpanel model tabbed interfaces, with selected distinguishing the active tab. For asynchronous feedback, alert announces urgent messages immediately, status announces polite updates, and progressbar exposes loading state. These last three matter enormously for synchronization, because asserting on a status region is how you wait for an operation to report completion without coupling to a spinner's CSS.

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

test('navigate a tablist and assert the selected tab and its panel', async ({ page }) => {
  await page.goto('/settings');

  // Scope to the tablist so we never match tabs elsewhere on the page.
  const tabs = page.getByRole('tablist', { name: 'Account settings' });

  // The 'selected' state reads straight from aria-selected in the tree.
  await expect(tabs.getByRole('tab', { selected: true })).toHaveText('Profile');

  // Activate a different tab by its role and accessible name.
  await tabs.getByRole('tab', { name: 'Security' }).click();
  await expect(tabs.getByRole('tab', { name: 'Security' })).toHaveAttribute('aria-selected', 'true');

  // The matching tabpanel becomes visible; assert on its named region.
  await expect(page.getByRole('tabpanel', { name: 'Security' })).toBeVisible();
});

Implicit versus explicit roles

Every rendered element has a role, but most elements get theirs implicitly from their tag name rather than from an explicit role attribute. A <button> is implicitly a button; an <nav> is implicitly navigation; an <h2> is implicitly a heading with level: 2. An explicit role comes from role="..." and overrides the implicit one — <div role="button"> exposes the button role even though a bare <div> exposes none. Playwright resolves both the same way because it reads the computed tree the browser builds, not the raw tag.

Two consequences follow for test authors. First, prefer native semantics in the application: a real <button> gives you keyboard support, focus behavior, and a stable role for free, where a <div role="button"> only fakes the role and often forgets the rest. Second, when a query unexpectedly returns zero elements, the cause is frequently a missing or wrong role — an anchor without href, a custom control that never set role, or an element hidden from the tree by aria-hidden="true". Reading the computed tree in the Playwright Inspector tells you the truth faster than inspecting the HTML, because the HTML can look correct while the computed role is absent.

How the accessible name is computed

The accessible name is the human-readable label the accessibility tree associates with an element, and it is what the name option matches against. The browser's accessible name computation walks a defined priority order, and understanding that order explains why some name matches behave unexpectedly.

The priority is: aria-labelledby (which dereferences one or more element IDs and concatenates their text) wins first; then aria-label (a literal string on the element); then a programmatically associated <label> for form controls (via for/id or wrapping); then the element's own descendant text content; and finally fallback attributes such as title and, for images, alt. Crucially, aria-labelledby and aria-label override visible text — so a button reading "OK" but carrying aria-label="Confirm deletion" matches { name: 'Confirm deletion' }, not { name: 'OK' }. This is a frequent source of confusion: the test author sees "OK" on screen and cannot find why { name: 'OK' } fails to match.

When you control the markup, lean on visible text content as the accessible name wherever possible, because it keeps the test, the screen-reader experience, and the visible UI in agreement. When you must label through ARIA, label deliberately and match on that exact string.

Async implementation and locator chaining

Role locators return lazy Locator objects that resolve when an action or assertion runs against them. Playwright's auto-waiting engine checks visibility, stability, and actionability before clicking, filling, or asserting, so an explicit waitFor() is rarely needed before a plain interaction. Add an explicit expect() only when you need to confirm a precondition beyond basic actionability — that a button has become enabled, or that a status region shows a specific message.

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

test('submit an application and confirm the status update', async ({ page }) => {
  await page.goto('/applications/new');

  // Match the button by its role and accessible name, not its CSS class.
  const submitButton = page.getByRole('button', { name: 'Submit Application' });

  // Assert the precondition: the control must be visible before we act on it.
  await expect(submitButton).toBeVisible({ timeout: 5000 });
  await submitButton.click();

  // The status region exposes role="status"; assert on its computed name/text.
  await expect(page.getByRole('status')).toHaveText('Processing...');
});

Locators chain to preserve scope. Calling .getByRole() on an existing locator restricts the search to that subtree, which is how you disambiguate a control that appears many times on a page — for example, the Edit button inside one specific table row rather than every row. Scoping by role keeps the chain readable and avoids the positional fragility you would inherit from a long CSS descendant path.

Polling for ARIA state transitions

Interactive widgets — accordions, menus, comboboxes — frequently mutate their ARIA attributes after the initial mount, as hydration completes or an expand animation finishes. A single getAttribute('aria-expanded') read can capture an intermediate value and produce a false result. The correct pattern is expect().toPass(), which re-runs the enclosed assertion on an interval until it succeeds or the timeout expires, so the check resolves only once the accessibility tree has settled.

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

test('wait for an accordion region to finish expanding', async ({ page }) => {
  await page.goto('/help-center');

  const label = 'Billing questions';
  // Target the collapsible region by role and its accessible name.
  const accordion = page.getByRole('region', { name: label });

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

  // Re-poll until aria-expanded settles on 'true'; intervals back off over time.
  await expect(async () => {
    const isExpanded = await accordion.getAttribute('aria-expanded');
    expect(isExpanded).toBe('true');
  }).toPass({ timeout: 8000, intervals: [500, 1000, 2000] });
});

This polling approach is the role-aware counterpart to the broader synchronization techniques in Handling Dynamic Content. Where that guide focuses on render readiness and network settling, here the signal you wait on is an ARIA property reaching a known value.

Filtering by accessible name, state, and properties

Precise targeting comes from combining a role with the right filters. The name option accepts a string for a case-insensitive substring match or a RegExp for exact and pattern matching; pass { exact: true } when a substring would be ambiguous. Beyond the name, getByRole() accepts state and property options that map directly to ARIA: checked, pressed, expanded, selected, disabled, and level (for headings). These let you isolate a single element among many that share a role and similar text.

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

test('read totals from rows whose accessible name matches Invoice', async ({ page }) => {
  await page.goto('/billing/history');

  // Restrict to rows whose computed name contains "Invoice".
  const rows = page.getByRole('row', { name: /Invoice/ });
  const count = await rows.count();

  const totals: string[] = [];
  for (let i = 0; i < count; i++) {
    // Within each matched row, find the cell named "Total" and read it.
    const cell = rows.nth(i).getByRole('cell', { name: 'Total' });
    const value = await cell.textContent();
    totals.push(value?.trim() ?? '');
  }

  expect(totals.length).toBe(count);
  expect(totals.every((t) => t.length > 0)).toBe(true);
});

State filters keep tests honest about user-visible conditions. getByRole('checkbox', { checked: true }) matches only ticked boxes; getByRole('button', { name: 'Menu', expanded: false }) matches the trigger only while its menu is collapsed. Because these conditions read from the accessibility tree, they assert the same thing a screen reader user would perceive, which is a stronger guarantee than checking a class like .is-active.

For headings, the level option pins to the document outline: getByRole('heading', { level: 1, name: 'Dashboard' }) matches the page's <h1> regardless of how the heading component wraps its text. This is far more durable than a selector tied to a specific tag-plus-class combination.

String matching versus RegExp and the exact option

The name option behaves differently depending on the value you pass. A plain string performs a case-insensitive, whitespace-trimmed substring match, so { name: 'Save' } matches a button named "Save", "Save draft", and "Auto-save". That leniency is convenient until two controls share a prefix, at which point the locator becomes ambiguous and a strict-mode violation is thrown the moment you act on it. Two tools resolve the ambiguity. Pass { name: 'Save', exact: true } to require the full accessible name to equal "Save" exactly (still trimmed, but no longer a substring). Or pass a RegExp, which gives you full pattern control: { name: /^Save$/ } anchors both ends, and { name: /invoice \d+/i } matches a dynamic, numbered label. Reach for exact: true when you know the literal string and for a RegExp when the name carries variable content.

Strict mode and resolving duplicate accessible names

Playwright's locators run in strict mode: an action against a locator that resolves to more than one element fails rather than silently picking the first. This is a feature — it surfaces ambiguity at the point of failure instead of letting a test click the wrong element. When several controls legitimately share a role and accessible name, disambiguate by scope rather than by index wherever you can. Scoping inside a named landmark or a specific table row is durable; reaching for .nth(2) reintroduces the positional fragility that role queries exist to avoid. Use .filter() to narrow by a child the target contains, or chain from a uniquely named ancestor, and fall back to .first() or .nth() only when the elements are genuinely interchangeable.

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

test('disambiguate two Delete buttons by scoping to a named row', async ({ page }) => {
  await page.goto('/projects');

  // Both rows expose a "Delete" button; scope to the row by its accessible name.
  const targetRow = page.getByRole('row', { name: /Project Apollo/ });

  // .filter() narrows the row to the one containing the archived badge,
  // so the chained button query cannot match the wrong project.
  const archivedRow = targetRow.filter({ has: page.getByText('Archived') });

  // Within that single row, the Delete button is now unambiguous.
  await archivedRow.getByRole('button', { name: 'Delete', exact: true }).click();

  // The confirmation dialog is itself a role; assert before confirming.
  const dialog = page.getByRole('alertdialog', { name: 'Confirm deletion' });
  await expect(dialog).toBeVisible();
  await dialog.getByRole('button', { name: 'Delete project' }).click();
});

Hidden elements and the includeHidden option

By default, getByRole() ignores elements that are hidden from the accessibility tree — those with display: none, visibility: hidden, the hidden attribute, or aria-hidden="true". This default is usually what you want, because it makes a role query match only what a user could actually perceive and interact with, mirroring the auto-waiting model. It also explains a class of "element not found" failures: a control rendered in the DOM but hidden from the tree (a collapsed menu, an off-screen drawer) will not match until it becomes visible. The fix is almost always to drive the UI to reveal the element first, not to bypass the filter.

When you genuinely need to assert on a hidden element — verifying that a panel is correctly hidden, for instance — pass { includeHidden: true } to make the query consider hidden nodes. Treat this as the exception; reaching for it routinely is a sign the test is inspecting implementation state rather than user-visible behavior.

The fallback ladder: getByLabel, getByPlaceholder, getByText, and getByTestId

getByRole() is the first rung, but Playwright's built-in locators form a deliberate ladder you descend only as semantics thin out. getByLabel() is the natural choice for form fields, matching an input by its associated <label> text — often more readable than getByRole('textbox', { name: ... }) for the same control. getByPlaceholder() targets inputs by placeholder text, useful when a design omits a visible label (though a real label is the better fix). getByText() matches any element by its rendered text and suits non-interactive content like a confirmation message or an empty-state notice. At the bottom sits getByTestId(), which matches a data-testid attribute: it carries no accessibility meaning, but it is an explicit, intentional contract between the application and the test, far more stable than a hashed class. Descend the ladder only when the rung above cannot express the target, and prefer adding a real label or role to the application over settling for a placeholder or test id. When even these cannot reach an element — a legacy control with no semantics at all — that is the boundary where the structural techniques in CSS & XPath Best Practices take over.

ARIA live regions and asynchronous status

Asynchronous flows need a signal that an operation finished, and live regions are the accessible, durable source of that signal. An element with role="status" (an implicit aria-live="polite" region) announces non-urgent updates such as "Saved" or "3 results found", while role="alert" (implicitly aria-live="assertive") announces urgent messages immediately. Because these regions exist specifically to convey state changes to assistive technology, asserting on them is the most semantically honest way to wait for a result. Instead of polling a spinner's class or sleeping a fixed interval, assert expect(page.getByRole('status')).toHaveText('Saved') and let the web-first assertion retry until the application reports completion. This keeps the synchronization aligned with what a user is actually told, and it ties directly into the render-and-network readiness patterns covered in Handling Dynamic Content.

Shadow DOM and graceful degradation

Accessibility locators pierce open shadow roots natively — Playwright traverses encapsulated subtrees as part of normal resolution, so a getByRole() call reaches an interactive control rendered inside a web component without any special syntax. That makes role-based queries the first choice for shadow-hosted UI. When a component exposes no semantics — a legacy widget built from unlabeled <div>s with click handlers — role queries cannot match, and you fall back to scoped structural targeting. Route those cases through the techniques in Shadow DOM Traversal, and treat the missing role as an accessibility defect worth filing rather than a permanent reason to abandon semantic selectors.

Each test runs in its own browser context by default, so fallback selectors registered in one test never leak into another. Keep any structural fallback scoped to the specific shadow host so it cannot accidentally match a sibling component with the same internal markup.

Accessibility audits in the pipeline

Role-based selectors only work when the application actually exposes correct roles and names, so it pays to validate that contract continuously. The accessibility snapshot API (page.accessibility.snapshot()) was deprecated in Playwright 1.29; for direct inspection use page.evaluate() to read computed ARIA properties, and for structured compliance reporting run @axe-core/playwright as part of your suite.

import AxeBuilder from '@axe-core/playwright';
import { test, expect } from '@playwright/test';

test('dashboard has no accessibility violations', async ({ page }) => {
  await page.goto('/dashboard');

  // Analyze the live page against the configured axe ruleset.
  const results = await new AxeBuilder({ page }).analyze();

  // Fail the test on any violation so role/name regressions are caught early.
  expect(results.violations).toEqual([]);
});

Running this alongside functional tests turns accessibility debt into a build signal: when a refactor strips a label or removes a role, the audit fails before the missing semantics can quietly break your role-based locators. Playwright Inspector and the trace viewer also surface the computed accessibility tree at the moment of failure, which makes diagnosing a "locator resolved to 0 elements" error a matter of reading the actual roles the page exposed rather than guessing.

Migration path from structural selectors

Teams rarely convert a suite in one pass. A workable order is to replace selectors as you touch each spec: when a test fails because a class changed, rewrite that locator as a role query instead of patching the class. Start with the highest-value, most-refactored screens, where structural churn causes the most maintenance. For controls that genuinely lack semantics, prefer adding a real role or label to the application over reaching for a brittle CSS path — the change improves the product and the test at once. Where a structural selector must remain, scope it tightly and document why, then revisit it when the component gains proper ARIA.

Frequently Asked Questions

When should I use getByRole instead of a CSS selector?

Default to getByRole() for any interactive or semantic element — buttons, links, form controls, headings, table cells — because the role and accessible name survive markup refactors that break CSS classes. Reach for a CSS or XPath selector only when an element exposes no usable role or accessible name, and scope that selector tightly to a stable container.

How does Playwright compute the accessible name that getByRole matches?

It uses the browser's accessible name algorithm, which resolves the name in priority order from aria-labelledby, then aria-label, then an associated <label>, then the element's own text content, and finally fallbacks like title. So a control labeled only through aria-label matches by that name exactly as one labeled by visible text.

Does getByRole work inside Shadow DOM?

Yes. Playwright pierces open shadow roots during resolution, so a getByRole() call reaches interactive controls rendered inside web components with no special syntax. Closed shadow roots or components that expose no semantics require a scoped structural fallback instead.

Back to overview