A CSS locator identifies HTML elements using CSS selector syntax. Playwright can use CSS selectors directly through page.locator().
Simple
Select elements using tags, IDs, classes and attributes.
Powerful
Combine selectors to target specific elements in complex DOM structures.
Composable
Build locators by chaining and filtering instead of creating huge XPath expressions.
page.locator("input")
Select all input elements.
page.locator("#username")
Select an element by ID.
page.locator(".login-button")
Select elements by class.
page.locator('input[name="email"]')
Select an input by its name attribute.
page.locator('button[type="submit"]')
Select a submit button.
from playwright.sync_api import Page
def test_basic_css_locators(page: Page):
page.goto("https://example.test/login")
page.locator("#username").fill("john@example.com")
page.locator("#password").fill("secret123")
page.locator('button[type="submit"]').click()
| Selector | Example | Meaning |
|---|---|---|
| ID | Element with id="email" | |
| Class | .primary-button | Element containing the class |
| Attribute | input[name="email"] | Input with a matching name |
| Multiple attributes | input[type="text"][name="email"] | Both attributes must match |
| Descendant | form input | Input inside a form |
| Child | form > button | Direct child button |
Imagine an enterprise login page:
<form id="login-form">
<input
id="email"
name="email"
type="email"
class="form-control"
/>
<input
id="password"
name="password"
type="password"
class="form-control"
/>
<button
type="submit"
class="login-button"
>
Sign In
</button>
</form>
from playwright.sync_api import expect
def test_login(page):
page.goto("https://example.test/login")
page.locator("#email").fill("john@example.com")
page.locator("#password").fill("Password123")
page.locator('button[type="submit"]').click()
expect(page).to_have_url(r".*/dashboard")
expect(
page.locator(".dashboard-header")
).to_be_visible()
Modern applications often have useful attributes that make CSS selectors much more precise.
# Exact attribute
page.locator('[data-testid="login-button"]')
# Attribute starts with
page.locator('[data-testid^="user-"]')
# Attribute ends with
page.locator('[data-testid$="-button"]')
# Attribute contains
page.locator('[data-testid*="login"]')
# Multiple attributes
page.locator(
'input[type="email"][name="username"]'
)
CSS becomes especially useful when multiple conditions need to identify one element.
# Login button with multiple attributes
page.locator(
'button.login-button[type="submit"]'
)
# Product card with a specific product ID
page.locator(
'div.product-card[data-product-id="1001"]'
)
# Enabled button
page.locator(
'button.primary:not([disabled])'
)
# Input inside checkout form
page.locator(
'#checkout-form input[name="cardNumber"]'
)
# Any input inside checkout
page.locator("#checkout input")
# Direct child
page.locator("#checkout > button")
# Button inside a card
page.locator(".product-card button")
# Input inside a specific form
page.locator(
'form#registration-form input[name="email"]'
)
Playwright supports CSS selector functionality, including useful pseudo-class patterns.
# First matching element
page.locator(".product").first
# Last matching element
page.locator(".product").last
# CSS nth-child
page.locator(".product:nth-child(2)")
# Not disabled
page.locator("button:not([disabled])")
# Checked checkbox
page.locator('input[type="checkbox"]:checked')
CSS is not always the best locator. The best selector depends on the application's DOM and what makes the element stable and understandable.
CSS Only
page.locator(
'div.header div.actions button'
).click()
This can become tightly coupled to the DOM structure.
Semantic Locator
page.get_by_role(
"button",
name="Save"
).click()
When accessible information is reliable, this can better express the user's intended interaction.
One practical advantage of Playwright's locator model is that you can combine a broad CSS locator with locator filtering.
from playwright.sync_api import expect
def test_product_price(page):
page.goto("https://example.test/products")
product = page.locator(
".product-card"
).filter(
has_text="Laptop Pro"
)
expect(product).to_be_visible()
price = product.locator(
".product-price"
)
expect(price).to_contain_text("$1,299")
Instead of creating one enormous CSS selector, you can identify the component and then narrow it down.
Consider an e-commerce application with multiple products.
<div class="product-card" data-product-id="1001">
<h3 class="product-name">Laptop Pro</h3>
<span class="product-price">$1,299</span>
<button class="add-to-cart">
Add to Cart
</button>
</div>
def test_add_laptop_to_cart(page):
page.goto("https://shop.example.test/products")
product = page.locator(
'.product-card[data-product-id="1001"]'
)
product.locator(
".add-to-cart"
).click()
expect(
page.locator(".cart-count")
).to_have_text("1")
Suppose a banking application displays transaction rows:
<div class="transaction"
data-transaction-id="TXN-10045">
<span class="merchant">
Amazon
</span>
<span class="amount">
-$120.00
</span>
<button class="view-details">
Details
</button>
</div>
def test_transaction_details(page):
page.goto("https://bank.example.test/transactions")
transaction = page.locator(
'.transaction[data-transaction-id="TXN-10045"]'
)
expect(transaction).to_be_visible()
expect(
transaction.locator(".merchant")
).to_have_text("Amazon")
transaction.locator(
".view-details"
).click()
expect(
page.locator(".transaction-details")
).to_be_visible()
Modern frameworks sometimes generate classes such as:
<button class="css-18x7ab2 primary-button">
Submit
</button>
Avoid Dynamic Class
page.locator(
".css-18x7ab2"
).click()
Prefer Stable Class
page.locator(
".primary-button"
).click()
Even better, if the application exposes a stable test contract:
page.locator(
'[data-testid="submit-order"]'
).click()
Playwright locators can be scoped step-by-step.
product = page.locator(
".product-card"
).filter(
has_text="Laptop Pro"
)
price = product.locator(
".product-price"
)
expect(price).to_have_text("$1,299")
A locator should normally be combined with Playwright's web-first assertions rather than immediately checking a boolean.
Avoid
assert page.locator(
".success-message"
).is_visible()
This performs an immediate check and does not express Playwright's retrying assertion model.
Preferred
expect(
page.locator(".success-message")
).to_be_visible()
The assertion waits for the expected UI state within Playwright's configured timeout.
| Requirement | CSS | XPath |
|---|---|---|
| ID | //input[@id='email'] | |
| Class | .login-button | //button[contains(@class,'login-button')] |
| Attribute | input[name="email"] | //input[@name='email'] |
| Parent traversal | Limited / different approach | Strong support |
| Readable simple selectors | Usually concise | Often more verbose |
Selenium also supports CSS selectors through By.CSS_SELECTOR.
from selenium.webdriver.common.by import By
driver.get(
"https://example.test/login"
)
driver.find_element(
By.CSS_SELECTOR,
"#email"
).send_keys(
"john@example.com"
)
driver.find_element(
By.CSS_SELECTOR,
"#password"
).send_keys(
"Password123"
)
driver.find_element(
By.CSS_SELECTOR,
'button[type="submit"]'
).click()
| Area | Playwright | Selenium |
|---|---|---|
| CSS selector API | page.locator("css") | find_element(By.CSS_SELECTOR, "css") |
| Locator object | Strong locator abstraction | WebElement-based interaction |
| Chaining | Natural locator chaining | Often requires additional element lookup |
| Filtering | filter() provides expressive narrowing | Usually additional lookup or XPath/CSS logic |
| Waiting | Locator actions/assertions integrate with Playwright waiting behavior | Explicit waits and Selenium's synchronization APIs are commonly used |
| Assertions | Web-first expect() | Typically external assertion framework or direct checks |
| CSS itself | Powerful CSS support | Powerful CSS support |
Suppose an order page contains:
<div class="order-row" data-order-id="ORD-1001">
<span class="customer-name">
John Smith
</span>
<span class="status paid">
Paid
</span>
<button class="view-order">
View
</button>
</div>
Playwright
from playwright.sync_api import expect
def test_order(page):
page.goto(
"https://admin.example.test/orders"
)
order = page.locator(
'.order-row[data-order-id="ORD-1001"]'
)
expect(order).to_be_visible()
expect(
order.locator(".customer-name")
).to_have_text("John Smith")
expect(
order.locator(".status")
).to_have_text("Paid")
order.locator(
".view-order"
).click()
Selenium
from selenium.webdriver.common.by import By
driver.get(
"https://admin.example.test/orders"
)
order = driver.find_element(
By.CSS_SELECTOR,
'.order-row[data-order-id="ORD-1001"]'
)
customer = order.find_element(
By.CSS_SELECTOR,
".customer-name"
)
status = order.find_element(
By.CSS_SELECTOR,
".status"
)
assert customer.text == "John Smith"
assert status.text == "Paid"
order.find_element(
By.CSS_SELECTOR,
".view-order"
).click()
Enterprise applications frequently contain tables with many rows. Avoid selecting "the third row" when a business identifier exists.
Fragile
page.locator(
"table tbody tr:nth-child(3)"
).locator(
".view-button"
).click()
If sorting or filtering changes, the third row may no longer represent the intended order.
Business Identifier
order = page.locator(
"table tbody tr"
).filter(
has_text="ORD-1001"
)
order.locator(
".view-button"
).click()
If you own the application, consider adding stable automation attributes.
<button
data-testid="checkout-submit"
class="primary-button"
>
Place Order
</button>
page.locator(
'[data-testid="checkout-submit"]'
).click()
Avoid
page.locator(
"div.container > div:nth-child(2) > " +
"div.content > form > div:nth-child(1) > " +
"input.form-control"
)
Preferred
page.locator(
'input[name="email"]'
)
Shorter is not automatically better, but stable and intention-revealing selectors are generally easier to maintain.
Avoid
import time
page.locator(
"#submit"
).click()
time.sleep(3)
assert page.locator(
".success"
).is_visible()
Preferred
from playwright.sync_api import expect
page.locator(
"#submit"
).click()
expect(
page.locator(".success")
).to_be_visible()
In a professional project, CSS locators can be encapsulated inside page objects.
from playwright.sync_api import Page
class LoginPage:
def __init__(self, page: Page):
self.page = page
self.email = page.locator(
'input[name="email"]'
)
self.password = page.locator(
'input[name="password"]'
)
self.login_button = page.locator(
'button[type="submit"]'
)
def open(self):
self.page.goto(
"https://example.test/login"
)
def login(self, email, password):
self.email.fill(email)
self.password.fill(password)
self.login_button.click()
from playwright.sync_api import expect
def test_login(page):
login = LoginPage(page)
login.open()
login.login(
"john@example.com",
"Password123"
)
expect(page).to_have_url(
r".*/dashboard"
)
Role
Use accessible roles when they accurately represent the user interaction.
get_by_role()
Label
Great for form fields associated with labels.
get_by_label()
Test ID
Useful when the application exposes a stable test contract.
get_by_test_id()
CSS
Excellent for stable classes, attributes and structural component targeting.
locator("css")
Text
Useful when visible text is the reliable identifier.
get_by_text()
XPath
Keep it available for cases where XPath expresses a relationship that other locators do not conveniently express.
locator("xpath=...")
The CSS selector language itself is not fundamentally exclusive to Playwright. Selenium can also use CSS selectors.
The practical difference comes from how Playwright combines locators with actions, filtering, auto-waiting behavior and web-first assertions.
| Capability | Practical Playwright Benefit |
|---|---|
| Locator abstraction | Represent an element or component as a reusable locator. |
| Locator chaining | Scope child elements naturally from a parent locator. |
| Filtering | Narrow collections using conditions such as text or nested elements. |
| Actionability | Actions account for relevant element readiness checks. |
| Web-first assertions | Assertions wait for the expected state rather than requiring manual polling. |
| Readable composition | Complex UI components can be modeled as smaller locator expressions. |
| Multiple locator styles | CSS can be combined with role, label, text and test-ID strategies. |
Imagine a production checkout flow:
from playwright.sync_api import expect
def test_customer_can_complete_checkout(page):
# Product
page.goto(
"https://shop.example.test/products"
)
product = page.locator(
'.product-card[data-product-id="1001"]'
)
product.locator(
".add-to-cart"
).click()
# Cart
expect(
page.locator(".cart-count")
).to_have_text("1")
page.locator(
'[data-testid="cart-link"]'
).click()
# Checkout
page.locator(
'[data-testid="checkout-button"]'
).click()
page.locator(
'input[name="address"]'
).fill(
"100 Main Street"
)
page.locator(
'input[name="city"]'
).fill(
"Pune"
)
# Payment
page.locator(
'[data-testid="payment-card"]'
).fill(
"4111111111111111"
)
page.locator(
'[data-testid="place-order"]'
).click()
# Validation
expect(
page.locator(".order-success")
).to_be_visible()
expect(
page.locator(".order-status")
).to_have_text("Confirmed")
- Using dynamically generated framework classes.
- Building extremely long DOM-dependent CSS paths.
- Depending on nth-child() when a business identifier exists.
- Using CSS when a stable accessible role or label is available.
- Using indexes simply because the element is the second or third match.
- Using hard waits after every click.
- Checking visibility with immediate boolean assertions instead of web-first assertions.
- Duplicating the same CSS selectors throughout every test.
- Putting all locator logic directly into large test methods.
- Changing locators without checking whether they represent a stable UI contract.
- Prefer stable attributes over generated CSS classes.
- Use IDs when they are stable and unique.
- Use data-testid or a similar agreed test attribute when appropriate.
- Use locator chaining to scope interactions to the correct component.
- Use filter() when it improves readability.
- Prefer semantic Playwright locators when they better describe user intent.
- Keep CSS selectors short enough to understand and maintain.
- Use web-first assertions for UI state validation.
- Keep selectors inside Page Objects or reusable component abstractions where appropriate.
- Avoid fixed waits as synchronization.
| Goal | Playwright Example |
|---|---|
| ID | page.locator("#email") |
| Class | page.locator(".login-button") |
| Attribute | page.locator('[name="email"]') |
| Multiple attributes | page.locator('input[type="email"][name="email"]') |
| Descendant | page.locator(".checkout input") |
| Direct child | page.locator(".checkout > button") |
| Not disabled | page.locator("button:not([disabled])") |
| Test ID | page.locator('[data-testid="save"]') |
| First locator | page.locator(".item").first |
| Last locator | page.locator(".item").last |
| Filtered component | page.locator(".card").filter(has_text="Laptop") |
Question: Does Selenium support CSS selectors?
Yes. Selenium has long supported CSS selectors through By.CSS_SELECTOR.
Question: Is Playwright CSS syntax fundamentally different?
The underlying CSS selector concepts are standard browser CSS concepts. The practical difference is the Playwright locator API surrounding them.
Question: Why can Playwright feel more powerful?
Playwright combines CSS selectors with locator chaining, filtering, actionability checks, automatic waiting behavior and web-first assertions.
Question: Should I always use CSS?
No. Use the locator strategy that gives you the best combination of stability, readability and user intent.
Question: What is a strong real-world CSS selector?
page.locator(
'[data-testid="checkout-submit"]'
)