XPath Recipes

56 expressions for the situations that come up every week, each with a robustness rating and a one-click playground link. Copy it, adjust the text or attribute, ship it.

Stable: test ids, ARIA, name, landmarks
Medium: text, classes, structure
Fragile: positions and indexes

Test Attributes

By data-testid

Target an element by its test id attribute

stable
//*[@data-testid='login-button'] Try it

Any test attribute

Match data-testid, data-test or data-cy in one expression

stable
//*[@data-testid='save' or @data-test='save' or @data-cy='save'] Try it

Test id inside a container

Scope a test id to a specific component

stable
//*[@data-testid='cart']//*[@data-testid='checkout-button'] Try it

Text Matching

Button by Text

Find a button containing specific text

medium
//button[contains(text(), 'Sign')] Try it

Exact visible label

Match the trimmed visible text of a button, tolerant of whitespace and nesting

medium
//button[normalize-space()='Sign In'] Try it

Text in any text node

Match when the text is split across several text nodes

medium
//p[text()[contains(., 'Forgot')]] Try it

Case-insensitive text

Lowercase the text with translate() before comparing (XPath 1.0 has no lower-case())

medium
//button[translate(normalize-space(), 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz')='sign in'] Try it

Text containing an apostrophe

Use double quotes, or concat() when both quote types appear

medium
//button[normalize-space()="Don't save"] Try it

Element's own text only

Match the direct text node, ignoring nested children

medium
//li[normalize-space(text())='Fruit'] Try it

Form Elements

Input by Label

Find the input that follows a label

stable
//label[normalize-space()='Email Address']/following-sibling::input Try it

Input via label for/id

Resolve the label's for attribute to the input's id, wherever the input sits

stable
//input[@id=//label[normalize-space()='Email Address']/@for] Try it

Checkbox inside a wrapping label

Find a checkbox by the label text that wraps it

stable
//label[contains(., 'Remember me')]//input[@type='checkbox'] Try it

Checkbox next to its label

When the label follows the checkbox as a sibling, walk backwards from the label

stable
//label[normalize-space()='Remember me']/preceding-sibling::input[@type='checkbox'][1] Try it

Option in a select

Locate a select by name and an option by visible text

stable
//select[@name='country']/option[normalize-space()='Germany'] Try it

Radio button by value

Pick one radio from a group by its value

stable
//input[@type='radio'][@name='plan'][@value='pro'] Try it

Submit button in a form

Scope the submit button to its form

stable
//form[@id='login-form']//button[@type='submit'] Try it

Validation message for a field

Error text rendered as a sibling of the input

medium
//input[@name='email']/following-sibling::*[contains(@class, 'error')] Try it

Tables

Table Cell by Header

Find a cell by computing the column index from its header

medium
//table//tr/td[count(//th[normalize-space()='Price']/preceding-sibling::th)+1] Try it

Row containing a cell

The row that has a cell with specific text

medium
//tr[td[normalize-space()='USB-C Hub']] Try it

Button in a specific row

Filter the row by content, then descend to the action

stable
//tr[td[normalize-space()='USB-C Hub']]//button[@data-action='edit'] Try it

Cell by row name and column header

Survives column reordering

medium
//tr[td[normalize-space()='Keyboard']]/td[count(//th[normalize-space()='Stock']/preceding-sibling::th)+1] Try it

Data rows, skipping the header

Rows that have td cells rather than th cells

medium
//table//tr[td] Try it

Last row in a table body

Parentheses make the index global rather than per-parent

fragile
(//tbody/tr)[last()] Try it

Lists & Cards

Button in the card with a title

Container-by-content, then the control inside it

medium
//article[.//h2[normalize-space()='Smart Watch Series 5']]//button[normalize-space()='Add to Cart'] Try it

Card that shows a badge

Select the container by something nested in it

medium
//article[.//span[normalize-space()='Sale']] Try it

List item by status attribute

Filter repeated items on a data attribute

stable
//li[@data-status='pending']//button Try it

Nth Item in List

Select a specific item from a list by position

fragile
(//ul[@class='items']/li)[3] Try it

Lists with more than N items

Count children inside a predicate

medium
//ul[count(li) > 5] Try it

Dialogs & Overlays

Button inside an open dialog

Anchor to the dialog landmark to avoid the page-level duplicate

stable
//*[@role='dialog']//button[normalize-space()='Save'] Try it

Dialog with a given title

Identify the dialog by its heading when several can be open

stable
//*[@role='dialog'][.//h2[normalize-space()='Delete team?']] Try it

Close button by aria-label

Icon buttons usually carry an accessible name

stable
//*[@role='dialog']//button[@aria-label='Close dialog'] Try it

Toast or status message

Live regions announce their content

medium
//*[@role='status' or @role='alert'][contains(., 'saved')] Try it

Attributes

Contains Class

Match partial class names

medium
//*[contains(@class, 'btn-primary')] Try it

Exact class token

Match the whole class token so 'btn' does not match 'btn-danger'

medium
//*[contains(concat(' ', normalize-space(@class), ' '), ' btn ')] Try it

ID prefix

Match the stable stem of a generated id

fragile
//input[starts-with(@id, 'input-')] Try it

Attribute ends with (XPath 1.0)

ends-with() does not exist in 1.0; use substring and string-length

medium
//a[substring(@href, string-length(@href) - string-length('.pdf') + 1) = '.pdf'] Try it

Attribute present

Existence test, regardless of value

medium
//input[@required] Try it

Attribute absent

not() tests for absence; HTML boolean attributes are true when present

medium
//button[not(@disabled)] Try it

Structural

Parent to Child

Navigate from parent to a specific child

medium
//div[@class='card']//h2 Try it

Buttons or button-like links

Union of two node-sets

medium
//button | //a[@role='button'] Try it

Axes

Sibling Navigation

Find an element relative to its sibling

medium
//h3[text()='Settings']/following-sibling::div[1] Try it

Nearest ancestor of a type

ancestor::x[1] is the closest one, because ancestor is a reverse axis

medium
//span[normalize-space()='Sold out']/ancestor::article[1] Try it

First input after a heading

following:: crosses containers; [1] keeps the nearest

medium
//h2[normalize-space()='Billing']/following::input[@name='postcode'][1] Try it

Cell to the left

preceding-sibling::td[1] is the immediately preceding cell

medium
//td[normalize-space()='Total']/preceding-sibling::td[1] Try it

SVG & Icons

Inline SVG element

//svg fails because of the namespace; local-name() works

medium
//*[local-name()='svg'] Try it

Button containing an icon

Match the icon by its data attribute, then the button around it

medium
//button[.//*[local-name()='svg'][@data-icon='trash']] Try it

Filled rating stars

Class attributes on SVG elements are plain strings; count the matches to get the rating

medium
//*[@data-testid='rating']/*[local-name()='svg'][contains(@class, 'filled')] Try it

Bar in an SVG chart

Data attributes inside a chart

medium
//*[local-name()='rect'][@data-quarter='Q3'] Try it

Position

First match on the page

(//x)[1] is global; //x[1] is per parent

fragile
(//button[normalize-space()='Remove'])[1] Try it

All but the first

Skip a header row or a placeholder option

medium
//select/option[position() > 1] Try it

Even rows

Arithmetic on position()

fragile
//tbody/tr[position() mod 2 = 0] Try it

Need it in your framework's syntax?

The code generator turns any of these into Selenium, Playwright, Cypress, Puppeteer, WebdriverIO or Robot Framework calls with quoting handled.

Open the code generator →