Playwright recommends user-facing locators, but it fully supports CSS and XPath through page.locator(). You will need these when dealing with legacy apps that have terrible HTML, complex table structures, or elements buried inside deeply nested components. Think of CSS and XPath as the screwdriver in your toolkit -- not the first thing you reach for, but essential when nothing else fits.
import { test, expect } from '@playwright/test';
test('CSS selector examples', async ({ page }) => {
await page.goto('/shopping');
// By class name
await page.locator('.product-card').first().click();
// By ID
await page.locator('#search-input').fill('laptop');
// By attribute
await page.locator('[data-status="active"]').click();
// Nested selectors
await page.locator('.cart-summary .total-price').textContent();
// Combining class + attribute
await page.locator('input[type="email"]').fill('test@example.com');
// Pseudo-selectors
await page.locator('tr:first-child td:last-child').textContent();
// Direct child
await page.locator('.nav > a.active').click();
});XPath is powerful but verbose. Prefix your XPath expression with xpath= to tell Playwright you are using XPath and not CSS.
import { test, expect } from '@playwright/test';
test('XPath selector examples', async ({ page }) => {
await page.goto('/banking');
// Find by exact text content
await page.locator('xpath=//button[text()="Login"]').click();
// Find by partial text
await page.locator('xpath=//span[contains(text(), "Balance")]').textContent();
// Navigate to parent
await page.locator('xpath=//td[text()="John"]/..').click();
// Find by attribute
await page.locator('xpath=//input[@placeholder="Search"]').fill('test');
// Combine conditions with and/or
await page.locator('xpath=//input[@type="text" and @name="username"]').fill('admin');
// Find nth element (XPath is 1-indexed!)
await page.locator('xpath=(//div[@class="product-card"])[1]').click();
// Find following sibling
await page.locator('xpath=//label[text()="Email"]/following-sibling::input').fill('a@b.com');
});Playwright adds its own pseudo-selectors that CSS does not have. These are powerful shortcuts.
| Pseudo Selector | What It Does | Example |
|---|---|---|
| :has-text("...") | Element contains this text (including children) | locator('.card:has-text("Laptop")') |
| :has(.selector) | Element contains a child matching this selector | locator('.card:has(img)') |
| :text("...") | Element's own text matches exactly | locator(':text("Add to Cart")') |
| :visible | Element is visible on screen | locator('.modal:visible') |
| >> (chaining) | Scope to child (like CSS >) | locator('.sidebar >> .menu-item') |
// Find the product card that contains "Laptop" text
const laptopCard = page.locator('.product-card:has-text("Laptop")');
// Find cards that have an image inside them
const cardsWithImages = page.locator('.product-card:has(img)');
// Find a visible modal (ignore hidden ones)
const activeModal = page.locator('.modal:visible');
// Chain scoping with >>
const menuItem = page.locator('.sidebar >> .menu-item:has-text("Settings")');XPath is 1-indexed. CSS nth-child is 1-indexed. But Playwright's .nth() method is 0-indexed. This is a common source of off-by-one bugs. (//div)[1] in XPath = .nth(0) in Playwright.
When you must use CSS/XPath, keep selectors short and stable. Avoid deeply nested paths like div.container > div.row > div.col-6 > div.card > h3. It breaks when any wrapper changes. Target the closest unique attribute instead.
Q: When would you use CSS or XPath selectors in Playwright instead of getByRole?
A: I use them when the HTML is not semantic -- no ARIA roles, no labels, no meaningful text. This happens often in legacy applications or with third-party components. Common scenarios: selecting table rows by cell content using XPath, targeting elements by custom data attributes using CSS, navigating parent-child relationships with XPath axes. I always try user-facing locators first and fall back to CSS/XPath only when needed. I also use Playwright-specific pseudo selectors like :has-text() which give me CSS convenience with text-based matching.
Key Point: page.locator() supports CSS and XPath selectors plus Playwright-specific pseudo selectors like :has-text() and :has(). Use them when user-facing locators are not enough.