Resource library

QA How-To

How to Use Playwright has-text selector (2026)

Learn the Playwright has-text selector with correct CSS syntax, matching rules, runnable TypeScript, locator alternatives, debugging, and 2026 best practices.

24 min read | 3,337 words

TL;DR

Use page.locator('article:has-text("Playwright")') to select an article containing that text. Always combine :has-text() with a specific CSS selector; for new tests, locator.filter({ hasText }) or user-facing locators are often clearer and support regular expressions.

Key Takeaways

  • Qualify :has-text() with a specific CSS selector so it does not match broad ancestors such as body.
  • Remember that :has-text() uses case-insensitive substring matching across descendant text and normalizes whitespace.
  • Prefer getByRole, getByText, or locator.filter({ hasText }) when they express user intent more clearly.
  • Use a regular expression with filter({ hasText }) when text variation needs explicit control.
  • Scope repeated matches by a stable container and keep strict locator actions enabled.
  • Enter iframes with FrameLocator and expect only open shadow roots to be traversable.
  • Assert the business result after an action instead of treating selector success as feature success.

The Playwright has-text selector is the custom CSS pseudo-class :has-text(). It matches an element when its text content, including descendant text, contains a specified substring. Matching is case-insensitive, whitespace is normalized, and the selector should always be combined with a specific CSS element, class, or attribute.

A correct example is page.locator('article:has-text("Playwright")'). A risky example is page.locator(':has-text("Playwright")') because broad ancestors, including body, can contain the same text. Modern Playwright code can often express the requirement more clearly with getByText, getByRole, or locator.filter({ hasText }).

This tutorial explains the CSS syntax, matching behavior, strictness, exact alternatives, frames, shadow DOM, debugging, and migration patterns with runnable TypeScript.

TL;DR

import { test, expect } from '@playwright/test';

test('opens the matching product card', async ({ page }) => {
  await page.setContent([
    '<article class="product">',
    '  <h2>Mechanical Keyboard</h2>',
    '  <button>View details</button>',
    '</article>',
    '<article class="product">',
    '  <h2>Wireless Mouse</h2>',
    '  <button>View details</button>',
    '</article>',
  ].join(''));

  const card = page.locator(
    'article.product:has-text("Wireless Mouse")',
  );

  await card.getByRole('button', { name: 'View details' }).click();
  await expect(card).toContainText('Wireless Mouse');
});
Requirement Preferred expression
CSS element containing a substring article:has-text("Mouse")
Semantic element by visible name getByRole('button', { name: 'Save' })
Any smallest visible text match getByText('Wireless Mouse')
Existing locator filtered by text locator.filter({ hasText: 'Mouse' })
Text with regular expression rules locator.filter({ hasText: /^Mouse /i })
Element containing a descendant element locator.filter({ has: childLocator })
Exact visible text getByText('Save', { exact: true })

Use :has-text() when CSS structure is already part of a justified locator. Use locator APIs when they communicate the domain more directly.

1. What the Playwright Has-Text Selector Matches

:has-text() is a Playwright extension to CSS selectors. It is not the native CSS :has() relational pseudo-class, and it is not a method named page.hasText(). You pass the complete selector string to page.locator() or locator.locator().

Given this markup:

<article class="release">
  <h2>Playwright 2026 Guide</h2>
  <p>Practical locator patterns</p>
</article>

The following selectors match the article:

page.locator('article:has-text("Playwright")');
page.locator('.release:has-text("locator patterns")');
page.locator('article.release:has-text("PLAYWRIGHT")');

Matching searches the candidate element's text, including text inside descendants. It is case-insensitive, trims leading and trailing whitespace, collapses repeated whitespace, and looks for a substring. That makes it convenient but broad.

The candidate is the CSS element before :has-text(), not necessarily the smallest element that directly renders the characters. In the example, article matches because its h2 descendant contains Playwright. If main, body, or html is also allowed as the candidate, those ancestors can match too.

This behavior is useful for selecting a card, row, list item, or article based on nested copy. It is less useful for locating one text node or a button by its accessible name.

The current locator API returns a Locator. Matching happens when an assertion or action runs, so it re-resolves during page updates. Strict actions such as click still require one target unless the operation is explicitly multi-element.

2. Set Up a Runnable :has-text() Test

Create a Playwright Test project:

npm init playwright@latest
npx playwright install

Save the following as tests/has-text.spec.ts:

import { test, expect } from '@playwright/test';

test.beforeEach(async ({ page }) => {
  await page.setContent([
    '<main>',
    '  <section class="plans">',
    '    <article class="plan">',
    '      <h2>Starter</h2>',
    '      <p>For one project</p>',
    '      <button>Select plan</button>',
    '    </article>',
    '    <article class="plan">',
    '      <h2>Team</h2>',
    '      <p>For up to ten members</p>',
    '      <button>Select plan</button>',
    '    </article>',
    '  </section>',
    '</main>',
  ].join(''));
});

test('selects the Team plan', async ({ page }) => {
  const team = page.locator('article.plan:has-text("Team")');

  await expect(team).toHaveCount(1);
  await team.getByRole('button', { name: 'Select plan' }).click();
});

Run it with:

npx playwright test tests/has-text.spec.ts

The selector first limits candidates to article.plan and then keeps the article whose complete text contains Team. The button is found semantically inside the matched card.

Do not collapse the entire workflow into one long CSS string such as article.plan:has-text("Team") button.select. Chaining a role locator makes the action easier to review and less sensitive to class changes.

The static example has no click behavior, so a real product test must assert the outcome, such as a selected state, updated total, or navigation. Selector matching is only test setup, not the acceptance result.

Add one negative example while learning, such as asserting that no Enterprise plan card matches. It confirms the candidate boundary and catches fixtures that accidentally place shared copy outside the intended component.

Run the focused fixture in every configured browser when selector behavior supports a cross-browser component.

3. Understand Case, Substring, Whitespace, and Descendants

Four matching rules explain most surprises.

First, string matching is case-insensitive:

await expect(
  page.locator('article:has-text("team")'),
).toHaveCount(1);

This can match an article containing Team. If letter case is a requirement, :has-text() is not the right assertion. Locate the element and use an exact text or attribute assertion that expresses the expected case.

Second, it searches for a substring. "Pro" can match "Product", "Professional", and "Profile". Qualify both the candidate and the phrase:

const proPlan = page.locator(
  'article.plan:has-text("Professional plan")',
);

Third, whitespace is normalized. Line breaks and repeated spaces are treated as normalized text for matching:

<article class="plan">
  <h2>Team
      plan</h2>
</article>
const team = page.locator('article.plan:has-text("Team plan")');

Fourth, descendant text counts. An article can match because a hidden nested template, status, or child label contains the phrase. Matching by text does not necessarily mean the exact phrase is the article's direct child.

When descendant ownership matters, combine structural conditions. Find a card with a heading or test ID rather than searching all of its combined copy. Semantic scoping frequently produces clearer failures than adding more words to a substring.

Use toContainText for a retrying assertion that a located container includes text. Do not read textContent immediately and compare manually unless the test truly needs raw DOM text.

4. Why a Bare Playwright Has-Text Selector Is Too Broad

The official guidance warns against using :has-text() without another CSS specifier. This selector is risky:

await page.locator(':has-text("Playwright")').click();

If the page contains the word, html, body, main, an article, and a nested span may all match. A strict click can fail because there are multiple targets. Worse, a positional workaround might click a broad container that was never intended to be interactive.

Qualify the candidate:

await page
  .locator('article.guide:has-text("Playwright")')
  .getByRole('link', { name: 'Read guide' })
  .click();

Useful candidate qualifiers include:

article:has-text("Release notes")
li.result:has-text("QA Engineer")
[data-testid="invoice-row"]:has-text("INV-1042")
dialog.settings:has-text("Notifications")

Specific does not mean deeply structural. A selector such as div:nth-child(3) > div.card:nth-child(2):has-text("Team") encodes layout and is likely to break. Choose a stable component boundary.

Do not add :visible automatically. If two matching elements exist and one is hidden, first ask why. A responsive layout may render desktop and mobile copies, or a stale modal may remain in the DOM. :visible can be a valid requirement, but it can also conceal duplicated UI.

When the failure concerns hidden duplicates, fixing locators that resolve to hidden elements provides a structured diagnostic path.

5. Prefer locator.filter({ hasText }) for Modern Locator Composition

The filter API narrows an existing Locator and accepts a string or RegExp:

const plans = page.getByRole('article');

const team = plans.filter({
  hasText: 'Team',
});

await team.getByRole('button', { name: 'Select plan' }).click();

In real markup, article may not have a useful role name, so a stable test ID can define the domain collection:

const product = page
  .getByTestId('product-card')
  .filter({ hasText: 'Wireless Mouse' });

await product.getByRole('button', { name: 'Add to cart' }).click();

filter has several advantages:

  • It composes with role, test ID, and other locator strategies.
  • It accepts RegExp for explicit variation.
  • It keeps the container and text condition separate in code.
  • It avoids quote escaping inside a CSS selector string.
  • It supports hasNotText for negative filtering.

Use an anchored expression when the card text has a known shape:

const completedJob = page
  .getByTestId('job-row')
  .filter({ hasText: /^Build 1042\s+Completed$/i });

The regex sees normalized locator text behavior, but design it against the actual card content. A complex expression over an entire row can be harder to maintain than locating named cells.

For most new test suites, filter({ hasText }) is the natural replacement for CSS :has-text() when the goal is "find this kind of component containing this text." The CSS form remains valid and useful, especially in existing selector-based code.

6. Compare :has-text() With getByText and Exact Matching

getByText locates an element based on text content and tends to select the smallest matching element according to Playwright's text locator behavior. :has-text() filters the CSS candidate you provide.

Given:

<article class="product">
  <h2>Wireless Mouse</h2>
  <button>Add to cart</button>
</article>

These locators express different targets:

const headingLikeText = page.getByText('Wireless Mouse');
const productCard = page.locator(
  'article.product:has-text("Wireless Mouse")',
);

Use getByText when the text-bearing element is the target. Use :has-text() or filter({ hasText }) when the containing card is the target.

For exact visible text:

const exact = page.getByText('Save', { exact: true });

Playwright also supports the CSS text pseudo-class :text-is() for exact text when CSS is necessary:

const save = page.locator('button:text-is("Save")');

:text-is() and :has-text() are not interchangeable. :text-is() seeks exact text on the smallest matching elements, while :has-text() performs substring search across the candidate and descendants.

Regular expressions do not belong inside :has-text(). Use getByText(/expression/) or filter({ hasText: /expression/ }).

If the element is an icon or compact control identified by title rather than visible text, Playwright getByTitle examples shows the dedicated title locator and accessibility tradeoffs.

Localization can change both exact and substring copy. When translated words are not the requirement, select the domain object through a test ID and reserve visible-text assertions for focused locale coverage.

7. Combine Text With Structure Using :has() or filter({ has })

Sometimes the requirement is not only text. It may be "the product card that contains a heading named Wireless Mouse and has an enabled Add button." Playwright supports both CSS :has() and locator filtering by a descendant Locator.

CSS form:

const available = page.locator(
  'article.product:has(button:not([disabled]))' +
  ':has-text("Wireless Mouse")',
);

Locator form:

const products = page.getByTestId('product-card');
const addButton = page.getByRole('button', { name: 'Add to cart' });

const available = products
  .filter({ hasText: 'Wireless Mouse' })
  .filter({ has: addButton });

await available.getByRole('button', { name: 'Add to cart' }).click();

The inner locator passed to has is evaluated relative to each outer candidate and must belong to the same frame. Do not anchor it at an unrelated parent that cannot be found inside the card.

You can filter out descendants or text:

const inStock = page
  .getByTestId('product-card')
  .filter({ hasNotText: 'Out of stock' });

Negative text filters are convenient but should align with the requirement. Absence of "Out of stock" does not necessarily prove an item is available. An enabled Add button or explicit availability status may be stronger.

Choose one expression style per locator. Mixing a long CSS :has() condition with several locator filters can be difficult to explain. Prefer the shortest composition that identifies the intended domain object without encoding presentation details.

8. Scope Text Matching in Lists and Tables

Repeated rows are where :has-text() is most valuable and most dangerous. A row can contain an identifier, customer name, and action buttons, so a narrow candidate is essential.

const invoice = page.locator(
  'tr:has-text("INV-1042")',
);

await invoice.getByRole('button', { name: 'Download' }).click();

This is readable if invoice IDs are unique. A role-based version can be stronger:

const invoice = page
  .getByRole('row')
  .filter({ hasText: 'INV-1042' });

await expect(invoice.getByRole('cell')).toHaveText([
  'INV-1042',
  'Asha Patel',
  '$120.00',
  'Paid',
  'Download',
]);

The assertion checks structured row content. In a real application, currency and locale should come from explicit test data.

For list counts, assertions can operate on the collection:

const passed = page.locator('li.result:has-text("Passed")');
await expect(passed).toHaveCount(3);

toHaveCount is a multi-element assertion and does not require one match. A later click does require one. If three passed results are expected but you need a specific build, add its unique identifier rather than choosing nth().

When a cell contains the same phrase in several rows, locate the row using a unique key and assert status inside it. This reduces false matches from customer names, notes, and hidden responsive labels.

Table headers and responsive labels can contribute descendant text. Inspect the actual accessibility and DOM structure before assuming that one visible cell is the only source of a phrase.

9. Handle Frames and Shadow DOM Correctly

A page-level locator does not cross iframe document boundaries. Enter the frame, then create the CSS locator:

import { test, expect } from '@playwright/test';

test('selects an item inside a catalog frame', async ({ page }) => {
  await page.goto('/embedded-catalog');

  const catalog = page.frameLocator('iframe[title="Catalog"]');
  const product = catalog.locator(
    'article.product:has-text("Wireless Mouse")',
  );

  await expect(product).toBeVisible();
  await product.getByRole('button', { name: 'Select' }).click();
});

If the iframe itself has matching text only in its child document, page.locator cannot see that child text. The frame boundary is an execution context, not just another DOM element.

Playwright CSS selectors pierce open shadow DOM by default. A qualified selector can locate a component descendant:

const account = page.locator(
  'user-card:has-text("Asha Patel")',
);

XPath does not pierce shadow roots, and closed shadow roots cannot be traversed. Test a closed component through its public behavior or request a stable external contract.

Text matching across component boundaries can be broad. A custom element may contain several internal labels. Prefer a public role or test ID when the component exposes one, then filter only within that component.

Build every related locator from the same frame scope. Mixing a page-level child locator into a frame-level filter produces an invalid relationship even when identical markup appears in both documents.

10. Debug Strictness and Unexpected Matches

A strict mode error means a single-target action resolved to several elements. Count and inspect matches before editing the selector:

const matches = page.locator(
  'article:has-text("Team")',
);

console.log('Match count:', await matches.count());
console.log(await matches.allTextContents());

Use Inspector and traces:

npx playwright test tests/has-text.spec.ts --debug
npx playwright test tests/has-text.spec.ts --trace=on

Common causes include:

  • The candidate selector is too broad.
  • Substring text appears in several cards.
  • A parent and child both satisfy the selector.
  • Desktop and mobile versions are rendered together.
  • Text exists in a hidden template.
  • A dialog remains mounted after closing.
  • The test is in the wrong tenant, locale, or state.
  • An iframe boundary was ignored.

Narrow by a domain container, unique identifier, accessible role, or stable test ID. Do not append first() until you can state why first position is the requirement.

If text arrives asynchronously, the Locator already re-resolves and assertions retry. Adding waitForTimeout does not improve identification. Assert the expected card count or visible state, then act.

Fixing Playwright strict mode violations covers ambiguity beyond text selectors.

Count inspection is diagnostic, not synchronization. After narrowing the locator, keep a web-first count or visibility assertion only when that state is part of the requirement.

11. Migrate Brittle CSS Text Selectors Incrementally

A legacy suite may contain selectors such as:

page.locator(
  'div.container > div:nth-child(2):has-text("Team") button.primary',
);

Do not rewrite every selector mechanically. Identify the product contract:

const plan = page
  .getByTestId('plan-card')
  .filter({ hasText: 'Team' });

await plan.getByRole('button', { name: 'Select plan' }).click();

This version separates the stable domain container, text condition, and user action. It also improves trace language.

A migration review should ask:

  1. Is visible text the intended identity?
  2. Is the text localized or frequently edited?
  3. Does a role and accessible name exist?
  4. Does a stable domain test ID exist?
  5. Can the container appear more than once?
  6. Is an exact or regex rule required?
  7. What assertion proves the action succeeded?

Keep :has-text() when the qualified CSS candidate is stable and readable. Modernization is not a ban on CSS. The goal is to remove layout coupling and ambiguous text, not to chase one preferred syntax.

Update a small feature at a time and run it across configured browsers. Selector migrations can expose genuine accessibility or duplicate-rendering defects that should not be hidden by a fallback.

Interview Questions and Answers

Q: What is :has-text() in Playwright?

It is a Playwright custom CSS pseudo-class used inside locator selector strings. It keeps candidate elements whose text, including descendant text, contains a specified substring. Matching is case-insensitive and whitespace-normalized.

Q: Why should :has-text() be qualified?

Without a CSS candidate, broad ancestors such as body can match because they contain all page text. This produces multiple matches or targets the wrong element. I qualify it with a stable card, row, article, class, or test attribute.

Q: How is :has-text() different from getByText()?

:has-text() filters the CSS candidate you name, so it is useful for selecting a containing card or row. getByText locates based on text content and generally targets the smallest matching element. The correct choice depends on whether the container or the text-bearing element is the subject.

Q: When would you use filter({ hasText }) instead?

I use it when I already have a role, test ID, or other Locator for the component. It composes clearly, supports string or RegExp input, and avoids CSS quote escaping. It is usually my first choice in new locator-based code.

Q: Does :has-text() support exact or regex matching?

It performs case-insensitive substring matching. For exact text, use getByText with exact true or a justified :text-is() CSS locator. For regular expressions, use getByText(RegExp) or filter({ hasText: RegExp }).

Q: Does the selector work inside iframes and shadow DOM?

It works inside an iframe after creating a FrameLocator for that frame. Playwright CSS locators pierce open shadow roots, but they do not traverse closed shadow roots. A page-level locator cannot see text inside a child frame.

Q: How do you fix a strictness error from :has-text()?

I inspect the count and text of all candidates, identify why the phrase repeats, and add domain scope or a unique key. I avoid first() unless ordering is actually the requirement. The error usually indicates missing intent in the locator.

Q: What should be asserted after using a has-text selector?

I assert the business outcome, such as selected state, navigation, saved data, or updated status. Matching and clicking a locator only prove input was sent to an element. They do not prove the feature worked.

Common Mistakes

  • Calling a nonexistent page.hasText() method.
  • Confusing Playwright :has-text() with native CSS :has().
  • Using :has-text() without a specific CSS candidate.
  • Expecting case-sensitive or whole-string behavior.
  • Putting a regular expression inside the CSS pseudo-class.
  • Selecting a container when the intended target is the text-bearing child.
  • Hiding duplicate matches with first() or nth().
  • Matching a short substring such as "Pro" across unrelated cards.
  • Ignoring text from descendants or hidden mounted content.
  • Crossing an iframe without FrameLocator.
  • Expecting XPath to pierce shadow roots.
  • Adding :visible before investigating why duplicate UI exists.
  • Using fixed sleeps for asynchronously rendered text.
  • Encoding layout with nested div and nth-child selectors.
  • Stopping after click without asserting the result.

Conclusion

Use the Playwright has-text selector as a qualified CSS filter, for example article.product:has-text("Wireless Mouse"). Remember its intentionally flexible rules: case-insensitive substring matching, normalized whitespace, and descendant text. Never use a bare :has-text() when broad ancestors can match.

For new code, compare the CSS form with locator.filter({ hasText }), getByText, and getByRole. Choose the expression that identifies the correct domain object, keep strictness enabled, and finish every interaction with an outcome assertion.

Interview Questions and Answers

Explain Playwright :has-text() matching.

It is a custom CSS pseudo-class that filters the candidate before it. It searches the candidate and descendants with case-insensitive substring matching and normalized whitespace. I always qualify it with a stable CSS selector.

How does :has-text() differ from CSS :has()?

:has-text() is a Playwright extension that matches text content. :has() is a relational CSS pseudo-class that matches a candidate based on a descendant selector. They can be combined, but they solve different conditions.

Would you choose :has-text() or locator.filter({ hasText })?

For new code, I usually choose filter when I already have a semantic or test ID locator for the component. It supports RegExp and keeps conditions readable. I keep :has-text() when a qualified CSS locator is already the clearest contract.

Why is a bare :has-text() selector risky?

Every ancestor containing the phrase can match, including body. Strict actions then fail or a positional shortcut targets the wrong element. A specific card, row, or article candidate prevents that ambiguity.

How do you express exact or regular expression text?

I use getByText with exact true for a stable exact string. For controlled variation, I pass a RegExp to getByText or filter hasText. :has-text() itself remains a flexible substring matcher.

How do frames affect :has-text()?

Locator queries stay in one frame. I create a FrameLocator for the target iframe and then build the CSS locator inside it. The same-frame rule also matters for descendant locators passed to filter has.

How would you debug several text matches?

I count the candidates, inspect allTextContents, and use Inspector or a trace. Then I scope by domain container, unique key, role, or test ID. I do not use first unless the product requirement is explicitly positional.

What makes a text-based locator maintainable?

The candidate is stable, the phrase is meaningful and unique in that scope, localization is considered, and strictness remains useful. The test also asserts the resulting business state, not just locator success. That combination makes a failure explain which observable contract changed.

Frequently Asked Questions

What is the Playwright has-text selector?

It is the custom CSS pseudo-class :has-text() used inside page.locator or locator.locator. It matches candidate elements containing the specified text in themselves or their descendants.

Is :has-text() case-sensitive in Playwright?

No. It performs case-insensitive substring matching and normalizes whitespace. Use getByText with exact true or an explicit regular expression when you need tighter control.

Why does :has-text() match body?

body contains the text of its descendants, so a bare :has-text() can match it. Always qualify the pseudo-class with a specific candidate such as article.product or tr.

What is the difference between :has-text() and hasText?

:has-text() is part of a Playwright CSS selector string. hasText is an option for locator.filter(), which composes with other locators and can accept a string or regular expression.

Can :has-text() use a regular expression?

No. Use locator.filter({ hasText: /pattern/ }) or getByText(/pattern/) for regular expression matching. Keep the expression anchored and specific when possible.

How do I make a Playwright text selector exact?

Use getByText('Save', { exact: true }) for a user-facing locator. If CSS is required, button:text-is("Save") provides exact text matching for the smallest matching element.

Does :has-text() work in an iframe?

Yes, after you enter the iframe with FrameLocator. Call frameLocator.locator('article:has-text("value")'); a page-level locator cannot cross the child document boundary.

Should I replace every :has-text() selector?

No. Keep qualified, readable CSS selectors that reflect a stable component contract. Replace broad or structural selectors when role, text, test ID, or filter composition communicates intent better.

Related Guides