Cheatsheet Β· Updated 2026-10-23

XPath and CSS selectors, side by side.

Use the same row for both columns: the CSS and the XPath find the same element. Prefer the shortest locator that a developer is unlikely to change.

Find by attribute

What you wantCSSXPath
By id#login//*[@id='login']
By class.btn-primary//*[contains(concat(' ', normalize-space(@class), ' '), ' btn-primary ')]
Tag and classbutton.btn-primary//button[contains(@class, 'btn-primary')]
By attributeinput[name='email']//input[@name='email']
Test id[data-testid='submit']//*[@data-testid='submit']
Attribute starts witha[href^='https']//a[starts-with(@href, 'https')]
Attribute ends withimg[src$='.png']//img[substring(@src, string-length(@src) - 3) = '.png']
Attribute containsa[href*='login']//a[contains(@href, 'login')]
Two attributesinput[type='text'][name='q']//input[@type='text' and @name='q']
Either of twoinput[name='q'], input[name='query']//input[@name='q' or @name='query']

Find by text and position

What you wantCSSXPath
Exact textNot possible in plain CSS//button[text()='Sign in']
Text containsNot possible in plain CSS//a[contains(text(), 'Forgot')]
Text with extra spacesNot possible in plain CSS//span[normalize-space()='Total']
First childul > li:first-child//ul/li[1]
Last childul > li:last-child//ul/li[last()]
Nth childtr:nth-child(3)//tr[3]
Nth of a typep:nth-of-type(2)(//p)[2]
Not this classli:not(.disabled)//li[not(contains(@class, 'disabled'))]

Move around the page

What you wantCSSXPath
Direct childform > input//form/input
Any descendantform input//form//input
Parentdiv:has(> input#email)//input[@id='email']/..
Ancestorform:has(#email)//input[@id='email']/ancestor::form
Next siblinglabel + input//label/following-sibling::input[1]
Any later siblingh2 ~ p//h2/following-sibling::p
Earlier siblingNot possible in plain CSS//input/preceding-sibling::label[1]
Input next to a labelNot possible in plain CSS//label[text()='Email']/following-sibling::input
Row that has a cell with a classtr:has(td.name)//tr[td[contains(@class, 'name')]]
Third cell in the row for "Asha"Not possible in plain CSS//tr[td[text()='Asha']]/td[3]

The same locator in code

ToolCSSXPath
Selenium (Java)driver.findElement(By.cssSelector("#login"))driver.findElement(By.xpath("//*[@id='login']"))
Playwright (TS)page.locator("#login")page.locator("xpath=//*[@id='login']")

Rules that keep locators stable

  • Prefer, in order: a test id, a role with its accessible name, a label, a stable attribute. Use text and position last.
  • Avoid absolute XPath like /html/body/div[2]/div[1]. Any layout change breaks it.
  • Avoid generated class names such as css-1x2y3z. They change on every build.
  • In Playwright, prefer page.getByRole("button", { name: "Sign in" }) and page.getByLabel("Email"). They read like what the user sees and survive markup changes.
  • Check a locator in the browser console first: $$('css') and $x('xpath') in Chrome DevTools.
  • :has() is supported by current browsers. On very old browsers, fall back to XPath for parent lookups.

Questions

Which is better, XPath or CSS selectors?

Use CSS when it can express the locator: it is shorter and easier to read. Use XPath when you need what CSS cannot do, such as matching by text or moving to an earlier sibling.

Can a CSS selector find an element by its text?

No. Plain CSS cannot match text. Use XPath with text() or normalize-space(), or Playwright's getByText and getByRole.

How do I select a parent element?

In XPath use /.. or the ancestor axis. In modern browsers CSS can use :has(), for example div:has(> input#email).

What is the difference between // and / in XPath?

A single slash selects a direct child. A double slash selects a descendant at any depth.

Also: Severity vs priority, settled

Start Learning Now!