Playwright Locator: Complete Guide to Finding Elements
Playwright locators are how you find elements on a page. The best locators use accessible attributes — role, label, placeholder, text — because they match how users interact with the page, not how it was built. This guide covers every locator type, when to use each, and how to filter and chain them for precision.
Key Takeaways
Use accessible locators first. getByRole, getByLabel, and getByPlaceholder are the most resilient. They're tied to what users see, not implementation details like CSS class names that change with every refactor.
getByRole is the most powerful locator. It uses ARIA roles (button, textbox, heading, link, etc.) and can be filtered by name, which is exactly how accessibility tools and screen readers navigate pages.
getByTestId is the escape hatch. When nothing else works, add data-testid attributes to elements. They're explicit, never change accidentally, and communicate intent in the codebase.
Chaining narrows scope. page.getByRole('listitem').filter({ hasText: 'Product X' }).getByRole('button', { name: 'Add to cart' }) is far more reliable than any CSS selector.
page.locator() handles CSS and XPath. For legacy apps or complex selectors, CSS and XPath still work — but they're the last resort, not the first.
What Is a Playwright Locator?
A Playwright locator represents how to find one or more elements on the page. Unlike direct element handles (ElementHandle), locators are lazy — they don't query the DOM until you perform an action. They also automatically retry until the element is visible and actionable, or until the test times out.
// This doesn't query the DOM yet
const button = page.getByRole('button', { name: 'Submit' });
// This queries the DOM, waits for the button, and clicks it
await button.click();
Locators replace the older page.waitForSelector() + page.click(selector) pattern. They handle waiting automatically.
Types of Playwright Locators
getByRole — The Best Locator
getByRole finds elements by their ARIA role and accessible name. It's the most recommended locator in Playwright because it matches how accessibility tools and screen readers navigate the page.
// Find a button by its visible text
await page.getByRole('button', { name: 'Sign in' }).click();
// Find a heading
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
// Find a link
await page.getByRole('link', { name: 'Learn more' }).click();
// Find a checkbox
await page.getByRole('checkbox', { name: 'Remember me' }).check();
// Find a textbox by its associated label
await page.getByRole('textbox', { name: 'Email address' }).fill('user@example.com');
Common ARIA roles:
| Role | Elements |
|---|---|
button |
<button>, <input type="submit"> |
link |
<a href="..."> |
heading |
<h1> through <h6> |
textbox |
<input type="text">, <textarea> |
checkbox |
<input type="checkbox"> |
radio |
<input type="radio"> |
combobox |
<select>, custom dropdowns |
listitem |
<li> |
img |
<img> |
dialog |
Modal dialogs |
Name matching options:
// Exact match (default)
page.getByRole('button', { name: 'Submit' })
// Case-insensitive
page.getByRole('button', { name: 'submit', exact: false })
// Regex
page.getByRole('button', { name: /submit/i })
getByLabel — Best for Form Inputs
getByLabel finds form controls associated with a <label> element. This is the preferred way to find text inputs, checkboxes, and selects when labels are present.
// Finds the input associated with this label
await page.getByLabel('Email address').fill('user@example.com');
await page.getByLabel('Password').fill('secret123');
// Works with aria-label too
await page.getByLabel('Search').fill('playwright');
<!-- getByLabel('Email address') matches this input -->
<label for="email">Email address</label>
<input id="email" type="email">
<!-- Also works with wrapping labels -->
<label>
Email address
<input type="email">
</label>
getByPlaceholder — For Inputs Without Labels
getByPlaceholder finds inputs by their placeholder text. Useful when forms don't have proper labels (common in older apps).
await page.getByPlaceholder('Enter your email').fill('user@example.com');
await page.getByPlaceholder('Search products...').fill('laptop');
getByText — Find by Visible Text
getByText finds elements containing specific text. Use it for non-interactive elements like paragraphs, list items, and table cells.
// Find element with exact text
await page.getByText('Welcome back, John').isVisible();
// Partial text match
await page.getByText('Loading').waitFor({ state: 'hidden' });
// Regex
await expect(page.getByText(/\d+ items in cart/)).toBeVisible();
Note: For buttons and links, prefer getByRole. getByText is better for non-interactive content.
getByAltText — For Images
getByAltText finds images by their alt attribute.
await expect(page.getByAltText('Product thumbnail')).toBeVisible();
await page.getByAltText('User avatar').click();
getByTitle — For Title Attributes
getByTitle finds elements with a matching title attribute.
await page.getByTitle('Close modal').click();
await expect(page.getByTitle('Loading indicator')).toBeHidden();
getByTestId — Explicit Test IDs
getByTestId finds elements by data-testid attribute (configurable). This is the explicit escape hatch — add test IDs when nothing else provides a stable locator.
await page.getByTestId('submit-button').click();
await page.getByTestId('product-card-42').getByRole('button', { name: 'Add to cart' }).click();
<button data-testid="submit-button">Submit</button>
You can change the attribute name in Playwright config:
// playwright.config.ts
export default defineConfig({
use: {
testIdAttribute: 'data-cy', // or any custom attribute
},
});
page.locator() — CSS and XPath
page.locator() accepts CSS selectors and XPath expressions for cases where the higher-level locators don't work.
// CSS selector
await page.locator('.submit-button').click();
await page.locator('#product-list .item:first-child').click();
// XPath
await page.locator('xpath=//button[contains(text(), "Submit")]').click();
// :text() pseudo-class (Playwright extension)
await page.locator(':text("Accept cookies")').click();
// :has() — elements containing another element
await page.locator('.card:has(.badge)').count();
CSS selectors to avoid:
// Bad — breaks when CSS classes change
page.locator('.btn-primary-v2-new')
// Bad — absolute DOM path
page.locator('div:nth-child(3) > ul > li:nth-child(2) > a')
// Better — semantic
page.getByRole('button', { name: 'Submit' })
Filtering Locators
Filters narrow down multiple matches to the specific element you want.
filter({ hasText })
// Product list with multiple items — find the specific one
const productCard = page.getByRole('listitem').filter({ hasText: 'Wireless Headphones' });
await productCard.getByRole('button', { name: 'Add to cart' }).click();
filter({ has })
// Find rows that contain a specific nested element
const rows = page.getByRole('row').filter({
has: page.getByRole('checkbox', { checked: true })
});
filter({ hasNot }) and filter({ hasNotText })
// Find items that don't have "Out of stock" badge
const availableItems = page.getByRole('listitem').filter({
hasNot: page.getByText('Out of stock')
});
// Find cards without a specific text
const activeCards = page.locator('.card').filter({ hasNotText: 'Archived' });
Chaining Locators
Chaining scopes a locator search within a parent element.
// Find the edit button within a specific table row
const row = page.getByRole('row', { name: 'John Doe' });
await row.getByRole('button', { name: 'Edit' }).click();
// Navigate a complex list
const sidebar = page.locator('[data-testid="sidebar"]');
await sidebar.getByRole('link', { name: 'Settings' }).click();
// Form within a dialog
const dialog = page.getByRole('dialog');
await dialog.getByLabel('Email').fill('test@example.com');
await dialog.getByRole('button', { name: 'Save' }).click();
Locating Multiple Elements
Locators return all matching elements when you use multi-element methods.
// Count elements
const count = await page.getByRole('listitem').count();
// Get all text content
const items = page.getByRole('listitem');
const texts = await items.allTextContents();
// Iterate over elements
const checkboxes = page.getByRole('checkbox');
for (const checkbox of await checkboxes.all()) {
await checkbox.check();
}
// nth() — access by index
await page.getByRole('listitem').nth(0).click(); // first item
await page.getByRole('listitem').last().click(); // last item
await page.getByRole('listitem').nth(2).click(); // third item (0-indexed)
Assertions with Locators
Playwright's expect() works directly with locators.
// Visibility
await expect(page.getByRole('dialog')).toBeVisible();
await expect(page.getByRole('dialog')).toBeHidden();
// Text content
await expect(page.getByRole('heading')).toHaveText('Dashboard');
await expect(page.getByRole('status')).toContainText('saved');
// Attribute value
await expect(page.getByRole('textbox')).toHaveValue('John Doe');
await expect(page.getByRole('link')).toHaveAttribute('href', '/about');
// State
await expect(page.getByRole('checkbox')).toBeChecked();
await expect(page.getByRole('button', { name: 'Submit' })).toBeEnabled();
await expect(page.getByRole('button', { name: 'Submit' })).toBeDisabled();
// Count
await expect(page.getByRole('listitem')).toHaveCount(5);
// Class
await expect(page.locator('.card')).toHaveClass(/active/);
All expect() assertions auto-retry until they pass or timeout — no manual waits needed.
Locator Best Practices
Priority Order
getByRole— use for buttons, links, headings, inputs with labelsgetByLabel— use for form inputs associated with labelsgetByPlaceholder— use for inputs with placeholders but no labelsgetByText— use for non-interactive contentgetByAltText— use for imagesgetByTestId— use when adding test IDs is practicalpage.locator(CSS)— last resort for legacy apps
Avoid These Patterns
// Fragile — breaks on style changes
page.locator('.btn-blue')
// Breaks on DOM restructuring
page.locator('form > div:nth-child(2) > input')
// Index-based without context
page.getByRole('button').nth(3)
// Generated class names
page.locator('.sc-bdXxxt.bFhmyM')
// Use these instead
page.getByRole('button', { name: 'Submit' })
page.getByLabel('First name')
page.getByTestId('checkout-button')
Use Playwright Codegen to Generate Locators
Playwright's code generator suggests the best locator as you click:
npx playwright codegen https://your-app.com
Click elements in the browser — Codegen shows the recommended locator in real time and prefers accessible locators automatically.
Debugging Locators
page.pause()
await page.pause(); // Opens Playwright Inspector
The Inspector lets you hover elements and see what locator Playwright suggests.
Locator Highlighting
// Highlight matched elements visually (useful in headed mode)
await page.getByRole('button', { name: 'Submit' }).highlight();
Check Locators in Browser Console
Open Chromium DevTools > Console and use:
playwright.$('button >> text=Submit')
playwright.$$('.card') // all matches
Common Scenarios
Login Form
await page.getByLabel('Email').fill('user@example.com');
await page.getByLabel('Password').fill('password123');
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
Select from Dropdown
// Native <select>
await page.getByLabel('Country').selectOption('United States');
await page.getByLabel('Country').selectOption({ value: 'US' });
await page.getByLabel('Country').selectOption({ label: 'United States' });
// Custom dropdown (click to open, then click option)
await page.getByRole('combobox', { name: 'Country' }).click();
await page.getByRole('option', { name: 'United States' }).click();
File Upload
await page.getByLabel('Upload CSV').setInputFiles('data.csv');
await page.getByLabel('Upload CSV').setInputFiles(['file1.csv', 'file2.csv']);
Accept/Dismiss Dialog
// Handle alert before it appears
page.once('dialog', dialog => dialog.accept());
await page.getByRole('button', { name: 'Delete account' }).click();
Wait for Navigation
await Promise.all([
page.waitForURL('/dashboard'),
page.getByRole('button', { name: 'Sign in' }).click(),
]);
Iframe
const frame = page.frameLocator('#payment-iframe');
await frame.getByLabel('Card number').fill('4111111111111111');
await frame.getByLabel('Expiry').fill('12/28');
Locator vs ElementHandle
Playwright has two ways to reference elements: locators (recommended) and element handles (legacy).
| Locator | ElementHandle | |
|---|---|---|
| Auto-waits | Yes | No |
| Retries on failure | Yes | No |
| DOM rebinding | Yes (re-queries each action) | No (stale after DOM change) |
| Recommended | Yes | No (legacy) |
// Old way — stale element errors
const el = await page.$('button');
await el.click(); // might throw "element is stale"
// New way — re-queries each time
const button = page.getByRole('button');
await button.click(); // always finds current element
Summary
| Locator | When to Use | Example |
|---|---|---|
getByRole |
Buttons, links, headings, inputs | getByRole('button', { name: 'Submit' }) |
getByLabel |
Form inputs with labels | getByLabel('Email address') |
getByPlaceholder |
Inputs without labels | getByPlaceholder('Search...') |
getByText |
Non-interactive text content | getByText('Welcome back') |
getByAltText |
Images | getByAltText('Logo') |
getByTestId |
Explicit test IDs | getByTestId('submit-btn') |
locator(CSS) |
Last resort | locator('.specific-class') |
Use accessible locators first. Chain and filter to be precise. Reserve CSS selectors for legacy apps where semantic markup isn't available.
Writing Playwright tests by hand? HelpMeTest generates Playwright tests from plain English descriptions and keeps them running — no locator maintenance required.