Introduction
Locators are the single most important concept in Playwright. Every click, fill, hover, drag, screenshot, and assertion begins by identifying an element — and the strategy you choose determines whether your tests are rock-solid or a constant source of flakiness. This guide covers every locator strategy Playwright ships with, in the order you should reach for them, with practical examples for each and the pitfalls that trip up most teams.
By the end you will know exactly which locator to reach for in any situation, how to chain and filter them for complex UIs, and how to debug the ones that stop working after a UI redesign.
What are Playwright Locators?
A locator is a lazy, re-queryable handle to an element on a page. Unlike a raw ElementHandle (the old API), a Locator does not point to a specific DOM node — it points to a query. Every time you act on it, Playwright re-runs the query. That is why locators survive React re-renders and DOM reshuffles that would break a Selenium test.
Playwright continuously evaluates a locator until the element is attached, visible, stable, enabled, and receiving events. Only then does it perform the action. This is why you almost never need explicit waits.
const submit = page.getByRole('button', { name: 'Submit' });
// The DOM may re-render between these two lines — Playwright re-queries automatically.
await expect(submit).toBeEnabled();
await submit.click();Why Playwright Locators are Better
- Auto-waiting on every action — no sleep(), no explicit waits.
- Automatic retries — actions retry until the actionability checks pass.
- Cross-browser identical semantics on Chromium, Firefox, and WebKit.
- Readable, semantic queries that survive CSS refactors.
- Better error messages — timeouts show what state the element was actually in.
- Immune to shadow DOM in most cases (getByRole pierces open shadow roots).
Instead of fragile XPath expressions coupled to your DOM structure, Playwright encourages locating elements the way real users perceive them — by role, label, and visible text.
Prerequisites
- A working Playwright setup — see our Getting Started guide if not yet installed.
- Basic HTML familiarity (roles, labels, form controls).
- A modern application to practise against (Playwright's own https://demo.playwright.dev/todomvc works well).
getByRole() — the recommended default
Role-based locators mirror how assistive technology (screen readers) sees your page. They are stable across CSS changes and simultaneously improve accessibility, which is why the Playwright team recommends them first.
await page.getByRole('button', { name: 'Login' }).click();
await page.getByRole('link', { name: 'Sign up' }).click();
await page.getByRole('textbox', { name: 'Email' }).fill('a@b.com');
await page.getByRole('checkbox', { name: 'Remember me' }).check();
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();Best for buttons, links, headings, form controls, tabs, menus, dialogs, and any element with an implicit or explicit ARIA role.
getByText()
Locate elements by the visible text a user actually reads. Prefer exact: true when the text is short and unique to avoid accidental matches on substrings.
await expect(page.getByText('Welcome back, Ada')).toBeVisible();
await page.getByText('Delete', { exact: true }).click();
// Regex works too — great for dynamic content:
await page.getByText(/Order #\d{5}/).click();getByLabel()
The best locator for form inputs — matches an input by its associated <label> text.
await page.getByLabel('Email').fill('admin@test.com');
await page.getByLabel('Password').fill('secret');
await page.getByLabel('I agree to the terms').check();getByPlaceholder()
await page.getByPlaceholder('Enter email').fill('admin@test.com');Useful when there is no visible label — common in search bars and modern minimalist forms. Prefer getByLabel when a real label exists.
getByTestId()
For custom components without semantic roles, add a data-testid attribute and query it. This is the escape hatch, not the default.
<div data-testid="shopping-cart">...</div>await page.getByTestId('shopping-cart').click();Enterprise teams often standardise on data-testid because it is intentionally decoupled from copy, CSS, and structure — the most stable strategy long-term. Configure the attribute name in playwright.config.ts if you use something other than data-testid.
CSS Selectors
CSS is a valid fallback when semantic locators cannot express what you need — for example, structural queries like the third row of a specific table:
await page.locator('#username').fill('admin');
await page.locator('table.orders tbody tr:nth-child(3)').click();
await page.locator('nav >> a.active').click();The >> combinator lets you chain sub-selectors. Reach for CSS only when semantic locators are unavailable, and keep selectors short.
XPath (last resort)
await page.locator("//button[text()='Login']").click();
await page.locator("xpath=//table//tr[td[text()='John']]").click();XPath is powerful but brittle — deeply nested paths break the moment the DOM changes. Playwright supports it, but Microsoft actively recommends against it for any query a semantic locator can express.
Locator Chaining
Compose locators to scope queries. This is the single most powerful pattern for reliable tests in complex UIs — you narrow to a container first, then query inside it:
// Find the row for John, then click its Edit button — never accidentally clicks a different row's button.
const row = page.getByRole('row', { name: /John/ });
await row.getByRole('button', { name: 'Edit' }).click();
// Scope inside a specific dialog:
const dialog = page.getByRole('dialog', { name: 'Confirm delete' });
await dialog.getByRole('button', { name: 'Delete' }).click();Filtering
filter() narrows a list of matches by text, another locator, or its inverse. Cleaner than long CSS selectors and much easier to read months later:
// Find the list item containing 'Playwright'
await page.getByRole('listitem').filter({ hasText: 'Playwright' }).click();
// Rows that contain a Delete button:
page.getByRole('row').filter({ has: page.getByRole('button', { name: 'Delete' }) });
// Rows that do NOT contain 'archived':
page.getByRole('row').filter({ hasNotText: 'archived' });nth(), first(), last()
await page.locator('.product').first().click();
await page.locator('.product').last().click();
await page.locator('.product').nth(2).click(); // 0-indexedBest Practices
The locator priority order
- getByRole() — the semantic default.
- getByLabel() — for form inputs with a visible label.
- getByPlaceholder() — for inputs without a label.
- getByText() — for visible copy the user reads.
- getByTestId() — for custom widgets with no useful semantics.
- CSS selector — for structural queries.
- XPath — genuinely last resort.
Anti-patterns to avoid
// Fragile — will break on any DOM change
//*[@id="content"]/div[4]/div[2]/button
// Fragile — coupled to styling
div.wrapper > div.inner > button.btn-primary
// Fragile — relies on order
page.locator('.card').nth(3)Checklist
- Prefer semantic locators — role, label, text.
- Keep selectors readable — a locator you cannot understand in three seconds is a locator you will not maintain.
- Add data-testid to custom widgets you own.
- Chain locators inside containers (rows, dialogs, cards).
- Prefer filter() over nth() whenever the element has a distinguishing property.
- Never assert on raw ElementHandle — always use a Locator with expect().
Common Pitfalls
- Using page.locator('text=Delete') instead of page.getByText('Delete') — both work, but getByText is the modern API and integrates with the Codegen recorder.
- Chaining .first() reflexively to silence 'strict mode violation' errors — this hides real bugs. Fix the locator to be unique instead.
- Assuming getByText matches invisible elements — by default it does. Add .filter({ visible: true }) if you need visibility.
- Writing selectors that depend on Tailwind or utility CSS classes like text-red-500 — these change on every design refresh.
- Using getByRole('button', { name: 'Save' }) when the accessible name comes from an SVG title that only Chrome exposes correctly. Test on WebKit too.
- Storing locators as ElementHandle (page.$) — they do not re-query. Always use Locator.
Debugging Tips
- Run npx playwright test --debug and use the Inspector's 'Pick locator' button to hover an element and get the recommended locator.
- Use await page.pause() inside a test to open the Inspector at that line.
- npx playwright codegen https://your-site.com records interactions and outputs semantic locators — a great starting point.
- await locator.count() tells you how many matches — useful when 'strict mode violation' fires.
- await locator.highlight() overlays the element on screen during --debug or UI mode.
- Enable trace: 'on' to see the exact selector used and every retry attempt in the Trace Viewer.
- If getByRole cannot find an element, inspect the DOM in DevTools — the element may be missing its aria-label or role attribute.
When to Use What
- Semantic buttons/links/inputs → getByRole with a name.
- Form fields with a visible label → getByLabel.
- Search boxes with only a placeholder → getByPlaceholder.
- Toast messages, banners, alerts → getByText or getByRole('alert').
- Custom widgets you own → add data-testid, use getByTestId.
- Third-party widgets you cannot modify → CSS selector on their public class names.
- Nothing above works → XPath, and open a follow-up to add better semantics.
FAQ
Why is getByRole recommended?
It mirrors how users and screen readers perceive the page. That means tests survive visual redesigns, they cover accessibility as a side effect, and the query is legible to any teammate.
When should I use XPath?
Only when no semantic locator, CSS selector, or filter combination can uniquely identify the element — genuinely rare in modern apps.
What is the most stable locator?
For applications your team owns: data-testid. For third-party widgets: getByRole with a name. Both survive CSS refactors and copy tweaks.
How do I fix 'strict mode violation: resolved to more than one element'?
Do not paper over it with .first(). Add filter({ hasText: 'unique value' }), chain inside a container, or pass a more specific name to getByRole.
Can I use jQuery-style selectors?
Playwright supports :has(), :is(), :not(), :nth-match(), :visible, and :text() extensions inside its CSS engine — but prefer the built-in filter() API for new tests, it reads better.
Does getByText handle shadow DOM?
Yes — Playwright's engine pierces open shadow roots automatically. Closed shadow DOM is a browser-level restriction no framework can bypass.
Summary
Playwright's locator API is intentionally opinionated: reach for role, label, and text first, testid for custom widgets, CSS for structural queries, and XPath practically never. Every locator is lazy, auto-retries, and pierces shadow DOM — that is what makes Playwright tests dramatically more stable than the previous generation.
If you master three things from this guide — getByRole with a name, chaining inside containers, and filter() over nth() — you will eliminate the majority of flaky-selector failures your team currently ships to CI.
Get Playwright tutorials in your inbox
Weekly tips, real-world examples, and framework patterns – no spam, unsubscribe anytime.