Playwright Locator: Complete Guide to Finding Elements

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

  1. getByRole — use for buttons, links, headings, inputs with labels
  2. getByLabel — use for form inputs associated with labels
  3. getByPlaceholder — use for inputs with placeholders but no labels
  4. getByText — use for non-interactive content
  5. getByAltText — use for images
  6. getByTestId — use when adding test IDs is practical
  7. page.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.

Read more