Role-based locators are the first choice in Playwright, but every real project also needs CSS selectors and XPath. This lesson teaches both from scratch with cheat sheets and runnable examples on real practice pages.
๐ What You’ll Learn: how Playwright detects CSS and XPath, CSS selector syntax, XPath functions and axes, reading table cells three ways, chaining and filtering locators, strict mode, testing selectors in DevTools, and CSS vs XPath.
๐งญ Where CSS and XPath Fit in Playwright
Playwright recommends user-facing locators first, such as getByRole, getByLabel and getByText, because they match how users see the page and survive UI refactors. You learned them in Playwright Locators.
But real applications are messy: missing labels, duplicate text, generated tables, legacy markup. That is when CSS selectors and XPath become essential. Both work through page.locator() and get all Playwright benefits: auto-waiting, retries and strictness.
Recommended priority:
getByRole(with accessible name)getByLabel,getByPlaceholder,getByText,getByAltText,getByTitlegetByTestId(data-testidor your configured attribute)- CSS selector (
#id,[data-test=...], stable classes) - XPath (complex relationships, text plus structure, legacy pages)
๐ค How Playwright Detects CSS vs XPath
page.locator('#user-name'); // CSS (default)
page.locator('//input[@id="user-name"]'); // XPath: starts with //
page.locator('..'); // XPath: parent of the current element
// Explicit prefixes (optional)
page.locator('css=#user-name');
page.locator('xpath=//input[@id="user-name"]');
Any selector starting with // or .. is treated as XPath; everything else is CSS unless you add a prefix.
๐จ CSS Selectors Cheat Sheet
All examples use the real login page of https://www.saucedemo.com, whose inputs look like this:
<input placeholder="Username" data-test="username" id="user-name" name="user-name" type="text" class="input_error form_input">
<input placeholder="Password" data-test="password" id="password" name="password" type="password" class="input_error form_input">
<input data-test="login-button" id="login-button" type="submit" value="Login" class="submit-button btn_action">
| Selector | Matches | Example |
|---|---|---|
#id | Element with that id | #user-name |
.class | Element with that class | .submit-button |
tag | All elements of that tag | input |
tag.class | Tag with a class | input.form_input |
[attr="value"] | Exact attribute value | [data-test="password"] |
[attr^="v"] | Attribute starts with | [id^="user"] |
[attr$="v"] | Attribute ends with | [id$="button"] |
[attr*="v"] | Attribute contains | [placeholder*="ser"] |
A B | B anywhere inside A | form input |
A > B | B as a direct child of A | form > div |
A + B | B immediately after A | label + input |
:nth-child(n) | The nth child of its parent | tr:nth-child(2) |
A, B | Either A or B | #user-name, #password |
import { test, expect } from '@playwright/test';
test('login with CSS selectors', async ({ page }) => {
await page.goto('https://www.saucedemo.com/');
await page.locator('#user-name').fill('standard_user');
await page.locator('[data-test="password"]').fill('secret_sauce');
await page.locator('input[type="submit"]').click();
await expect(page).toHaveURL(/inventory/);
});
๐ก Tip: Prefer ids and test attributes (data-test, data-testid) over classes. Classes such as css-1x2y3z generated by CSS-in-JS libraries change on every build.
๐งฌ XPath Cheat Sheet
| XPath | Matches |
|---|---|
//input | Any input anywhere |
//input[@id='user-name'] | Input with id user-name |
//input[@type='submit' and @value='Login'] | Both conditions |
//input[@id='x' or @name='x'] | Either condition |
//div[text()='Swag Labs'] | Element whose own text is exactly Swag Labs |
//h4[contains(text(),'Accepted')] | Text contains a substring |
//input[starts-with(@id,'user')] | Attribute starts with |
//td[normalize-space()='Jason'] | Text with surrounding spaces trimmed |
(//tr)[2] | The second tr in the whole document |
//td[2] | Second td within each row |
XPath axes: move around the DOM
Axes are XPath’s superpower: they let you find an element relative to another, which CSS cannot do upwards.
| Axis | Moves to |
|---|---|
parent::tag or .. | The parent element |
ancestor::tag | Any ancestor (parent, grandparent…) |
following-sibling::tag | Siblings after the element |
preceding-sibling::tag | Siblings before the element |
descendant::tag | Any element inside |
๐ Real Example: Tables with CSS, XPath and Filters
The practice page https://the-internet.herokuapp.com/tables has a table #table1 with the columns Last Name, First Name, Email, Due, Web Site and Action. Let’s read Jason Doe’s amount due ($100.00) three different ways.
import { test, expect } from '@playwright/test';
test.describe('read a table cell', () => {
test.beforeEach(async ({ page }) => {
await page.goto('https://the-internet.herokuapp.com/tables');
});
test('with CSS nth-child', async ({ page }) => {
// 3rd row, 4th column (Due)
const due = page.locator('#table1 tbody tr:nth-child(3) td:nth-child(4)');
await expect(due).toHaveText('$100.00');
});
test('with XPath following-sibling', async ({ page }) => {
// From the "Jason" cell, the 2nd cell after it is "Due"
const due = page.locator("//table[@id='table1']//td[text()='Jason']/following-sibling::td[2]");
await expect(due).toHaveText('$100.00');
});
test('with Playwright filter (recommended)', async ({ page }) => {
const row = page.locator('#table1 tbody tr').filter({ hasText: 'Jason' });
await expect(row.locator('td').nth(3)).toHaveText('$100.00');
await expect(page.locator('#table1 tbody tr')).toHaveCount(4);
});
test('click edit in the row of a user (XPath parent)', async ({ page }) => {
await page.locator("//table[@id='table1']//td[text()='Doe']/parent::tr//a[text()='edit']").click();
await expect(page).toHaveURL(/#edit/);
});
});
The filter version is the easiest to read: it finds the row by meaningful text instead of a hard-coded row number, so it keeps working when rows are re-ordered or new rows are added. For more table techniques see reading data from web tables.
๐ Combine Selectors with Playwright Locator Methods
You do not have to put everything in one long selector. Chain and filter instead:
// Chain: find inside another locator
const form = page.locator('form');
await form.locator('#user-name').fill('standard_user');
// Filter by text or by a child element
page.locator('#table1 tbody tr').filter({ hasText: 'Conway' });
page.locator('tr').filter({ has: page.locator('a', { hasText: 'delete' }) });
// Pick from several matches
page.locator('#table1 tbody tr').first();
page.locator('#table1 tbody tr').last();
page.locator('#table1 tbody tr').nth(1); // zero-based
// Mix CSS and role locators
page.locator('#table1').getByRole('link', { name: 'edit' }).first();
โ ๏ธ Common mistake: Playwright locators are strict: an action like click() fails if the selector matches more than one element. Fix it by making the selector more specific or by using filter, first() or nth() deliberately, not by habit.
๐งช Test Your Selectors Before Using Them
- Browser DevTools console:
document.querySelectorAll('#table1 tbody tr')for CSS,$x("//td[text()='Jason']")for XPath (Chrome). - DevTools Elements panel: press Ctrl + F and type a CSS selector or XPath to highlight matches.
- Playwright Inspector / VS Code “Pick locator”: click an element and Playwright suggests the best locator. See Playwright Codegen.
- UI mode:
npx playwright test --uilets you try locators against a live snapshot.
โ๏ธ CSS vs XPath: Which Should You Use?
| CSS | XPath | |
|---|---|---|
| Readability | Short and familiar | Longer |
| Match by text | Not in standard CSS (use filter or getByText) | Yes, text() and contains() |
| Move to parent or ancestor | No | Yes, with axes |
| Shadow DOM | Playwright CSS pierces open shadow DOM | Does not pierce shadow DOM |
| Typical use | ids, attributes, structure | text plus structure, relative navigation |
Rule of thumb: use role-based locators first, CSS for ids and test attributes, and XPath only when you need text-based or upward navigation that filters cannot express.
๐ซ Selectors to Avoid
- Absolute XPath such as
/html/body/div[2]/div/form/div[1]/input: breaks with any layout change. - Long CSS chains such as
div > div > div:nth-child(3) > span. - Auto-generated ids and classes (
#ember123,.css-1kq9xd). - Index-only selectors when a stable text or attribute exists.
๐ Assignment
Use https://www.saucedemo.com and https://the-internet.herokuapp.com/tables:
- Write three different CSS selectors for the Sauce Demo password field and use one in a test.
- Write an XPath that finds the login button using both
@typeand@value. - On the tables page, assert that the email of Frank Bach is
fbach@yahoo.comusing XPath withfollowing-sibling. - Do the same with
filter({ hasText })andnth(). - Count the rows of
#table1and assert there are 4. - Click the delete link in Tim Conway’s row using an XPath with
parent::tr. - Bonus: rewrite every selector from tasks 1 to 6 using role-based locators where possible, and note which ones were not possible.
โ FAQ
๐ค How do I use XPath in Playwright?
Pass it to page.locator(): page.locator("//input[@id='user-name']"). Selectors starting with // are detected as XPath automatically, or prefix them with xpath=.
๐ค How do I use a CSS selector in Playwright?
page.locator('#user-name') or page.locator('[data-test="password"]'). CSS is the default selector engine for locator().
๐ค Is XPath slower than CSS in Playwright?
The difference is negligible in practice. Choose based on readability and stability; prefer role-based locators and CSS, and use XPath when you need its axes or text matching.
๐ค Why does XPath not find my element inside a web component?
XPath does not pierce shadow DOM. Use a CSS selector or a role/text locator, which Playwright resolves inside open shadow roots.
๐ค How do I select by text with CSS in Playwright?
Standard CSS cannot match text. Use getByText(), or locator('tr').filter({ hasText: 'Jason' }).
๐ฏ Next Steps
- ๐ญ Actions: Click, Fill, Type, Check, Press and Hover
- ๐ญ Record Tests with Playwright Codegen
- ๐ญ Playwright Locator Interview Questions
- ๐ญ Playwright Tutorial with TypeScript
๐ฌ Watch the full Playwright tutorial series on YouTube!
Subscribe to SqaToolsByDY • Practice on sqatools.in
1 thought on “CSS and XPath Selectors in Playwright: Complete Guide with Examples”