skip to content

In a Playwright CSS selector, what is the difference between :has() and :has-text()?

level: middleimportance: should knowfreq 52%

answer

  1. One argument is a selector, one is not
  2. Ancestors versus the smallest container
  3. Case and whitespace handling differ
  4. Pair it with a tag or class
  5. Index counting starts at one here

basics

~20 s

The :has() pseudo-class takes a selector and keeps elements that contain a match for it. The :has-text() pseudo-class takes a string and keeps elements whose subtree contains that text, matched case-insensitively as a trimmed substring.

solid answer

~40 s

Both narrow a candidate set by what an element contains, but they take different arguments. `:has(sel)` is the standard CSS pseudo-class: `article:has(span.chip-blocker)` keeps articles containing a blocker chip, with the inner selector evaluated relative to the element's `:scope`. `:has-text("str")` is Playwright's own: it keeps any element whose subtree contains that text, matching case-insensitively on a whitespace-trimmed substring. Because it matches *any* ancestor, `:has-text()` used alone also matches `html` and `body`, so always pair it with a tag or class -- `article.issue-card:has-text("needs triage")`. Its narrower relatives `:text()`, `:text-is()` and `:text-matches()` match the smallest element holding the text instead, with `:text-is()` being case-sensitive and exact.

code

typescript · 8 lines
typescript
// Board card that contains a blocker chip: :has() takes a selector.
await page.locator('article.issue-card:has(span.chip-blocker)').click();

// Board card mentioning a phrase anywhere inside: :has-text() takes a string.
await page.locator('article.issue-card:has-text("needs triage")').click();

// The smallest element holding the text, third match on the page (one-based).
await page.locator(':nth-match(.issue-card :text("Reopen"), 3)').click();

go deeper

for a junior

Learn the argument each one takes: a selector goes inside :has(), a quoted string goes inside :has-text(). Always attach either to a tag or class rather than using them on their own.

for a middle

Explain the reach difference: :has-text() keeps any ancestor whose subtree holds the text, while :text() and :text-is() land on the smallest element. Know that matching trims and normalises whitespace.

for a senior

Recognise these as a fallback below role and test-id lookups, and push back on :nth-match() in review: positional matching across a whole page is the kind of selector that fails on an unrelated layout change.

for a principal

Decide how much of this pseudo-class vocabulary your suite is allowed to use. Every extra construct is one more thing a new test author must learn and one more way a selector can quietly change meaning.

## Two pseudo-classes, two kinds of argument Playwright's CSS engine accepts standard CSS plus a set of pseudo-classes it implements itself. Two of them look alike and are constantly confused, because both narrow a candidate set by what an element *contains*: - **`:has(selector)`** takes another **selector**. It keeps an element if at least one element matching that selector exists inside it, evaluated relative to the element's `:scope`. So `article:has(span.chip-blocker)` keeps the articles that contain a blocker chip. - **`:has-text("string")`** takes a **string**. It keeps an element if the given text appears anywhere in its subtree -- in the element itself, a child, or any descendant. Matching is case-insensitive, whitespace-trimmed and substring-based, so `article:has-text("needs triage")` keeps the articles mentioning that phrase. The practical consequence: `:has()` composes with the whole CSS language, while `:has-text()` is convenience sugar over one very common check. ## The text family, and how deep each one reaches `:has-text()` is the widest of Playwright's text pseudo-classes. The others narrow the match to the smallest element that actually holds the text: | Pseudo-class | Matches | Case | Match kind | |---|---|---|---| | `:has-text("x")` | any ancestor whose subtree contains the text | insensitive | substring | | `:text("x")` | the smallest element containing the text | insensitive | substring | | `:text-is("x")` | the smallest element whose text is exactly that | sensitive | full string | | `:text-matches("re", "i")` | the smallest element whose text matches the regex | per flags | regex | Two details bite regularly. Text matching **normalises whitespace** -- runs of spaces collapse to one, line breaks become spaces, and leading and trailing whitespace is ignored -- so a label split across lines in the markup still matches the phrase a user reads. And `<input>` elements of type `button` or `submit` are matched by their `value` attribute rather than by text content, because they have no text content to speak of. ## The trap: `:has-text()` with nothing beside it Because `:has-text()` matches *any* ancestor, using it alone is almost always wrong: ```css /* Wrong: matches html, body, every wrapper div, and the card. */ :has-text("needs triage") /* Right: the pseudo-class narrows an already-specific selector. */ article.issue-card:has-text("needs triage") ``` Always pair it with a tag, class or attribute that pins the element you actually want. The same rule does not apply to `:text()`, which by definition lands on the smallest container. ## Choosing among several matches with `:nth-match()` When a board renders the same control on every row, `:nth-match(selector, n)` picks one: 1. It evaluates the inner selector across the whole scope, not within a single parent. 2. The index `n` is **one-based**, so `:nth-match(:text("Reopen"), 3)` is the third match. 3. Unlike the standard `:nth-child()`, the matched elements need not be siblings -- they can sit anywhere in the tree. Positional selection is inherently fragile, so treat `:nth-match()` as a last resort behind a container-scoped locator. ## Other pseudo-classes worth knowing about - `:is()`, `:not()`, `:where()` and `:scope` behave as they do in standard CSS. - A comma-separated list is an OR, as in `button:has-text("Reopen"), button:has-text("Reopened")`. - The layout pseudo-classes `:right-of()`, `:left-of()`, `:above()`, `:below()` and `:near()` exist but are **deprecated** and may be removed; matching by layout changes answer when the page shifts by a pixel, so do not build a suite on them.

  • How does :text() differ from :has-text() on the same markup?
    `:has-text()` matches every ancestor whose subtree contains the string, so on `<article><div>Reopen</div></article>` it matches the article, the div and every wrapper above them. `:text()` matches only the smallest element containing the text, which here is the div. Both are case-insensitive substring matches on trimmed, whitespace-normalised text.
  • Why does :nth-match() not behave like the standard :nth-child()?
    `:nth-child()` counts an element's position among its siblings under one parent. `:nth-match(sel, n)` evaluates the inner selector across the whole scope and takes the n-th result wherever it sits in the tree, so the matches need not share a parent. The index is one-based, unlike the `nth=` engine, which is zero-based.
  • How is text matched for an <input type=submit> element?
    Input elements of type `button` and `submit` carry no text content, so Playwright's text pseudo-classes match them on their `value` attribute instead. `:text("Save issue")` therefore matches `<input type=submit value="Save issue">` even though the DOM node has no text child.

saying these in an interview costs you the question

  • Passing a bare string to :has() as if it matched text
  • Using :has-text() alone and being surprised body matches
  • Assuming :has-text() is case-sensitive
  • Thinking :nth-match() is zero-based
  • Expecting :nth-match() to count siblings like :nth-child()
  • Treating the deprecated layout pseudo-classes as a stable strategy