ScreenshotNeo

BlogHow-to

How to Find Buttons by Text in Cypress

Use cy.contains() to find Cypress buttons by visible text, match exact labels, scope duplicates, handle retries, and avoid flaky selectors.

By the ScreenshotNeo team30 September 20268 min read

How to Find Buttons by Text in Cypress

Use cy.contains('button', 'Save').click() to find and click a Cypress button by its visible text. The string is a substring match, so it can also match labels such as “Save draft.” For an exact label, use an anchored regular expression:

cy.contains('button', /^Save$/).click()

Cypress retries the query until a match exists, but a found element is not necessarily visible. Add .should('be.visible') when the test represents a user interaction.

1. The basic pattern

Create a Cypress test and restrict the text search to buttons:

describe('save form', () => {
  it('clicks the Save button', () => {
    cy.visit('/settings')
    cy.contains('button', 'Save').click()
  })
})

The selector argument (button) prevents Cypress from choosing a heading, container, or link that happens to contain the same text. Cypress documents cy.contains(selector, content) for this purpose and yields at most one element. See the cy.contains() API documentation.

2. Match an exact button label

A normal string performs substring matching:

cy.contains('button', 'Save') // matches “Save” and potentially “Save draft”

Anchor a regular expression when the whole visible label must equal the expected text:

cy.contains('button', /^Save$/).click()

If markup or formatting can add surrounding whitespace, use a whitespace-tolerant expression:

cy.contains('button', /^\s*Save\s*$/).click()

Cypress collapses runs of whitespace in ordinary element text before matching. Text inside a <pre> element is matched as written. A regular space in your query can match a non-breaking space in HTML.

3. Case-insensitive matching

String matching is case-sensitive by default. Pass matchCase: false when capitalization is not part of the behavior:

cy.contains('button', 'save', { matchCase: false }).click()

The option also applies to regular expressions:

cy.contains('button', /^save$/i).click()

Do not combine a case-insensitive regular-expression flag with matchCase: true; Cypress reports that combination as an error.

4. Wait for a visible button and its result

cy.contains() retries while looking for a matching element, and Cypress retries chained assertions. Add an explicit visibility assertion when a hidden duplicate could exist:

cy.contains('button', 'Save')
  .should('be.visible')
  .click()

cy.contains('Saved')
  .should('be.visible')

For a slow page, increase the timeout for this query and its chained assertions:

cy.contains('button', 'Save', { timeout: 15000 })
  .should('be.visible')
  .click()

The default comes from Cypress’s defaultCommandTimeout configuration. Prefer waiting on a meaningful state or element instead of adding arbitrary delays.

5. Scope the search to the correct part of the page

Repeated labels are common in tables, cards, and dialogs. Scope the query to a row or container so the test selects the intended control.

Scope a text query to the relevant row or dialog before clicking.
Scope a text query to the relevant row or dialog before clicking.

Button in a table row

cy.contains('tr', 'Jane')
  .contains('button', 'Edit')
  .click()

Both commands are queries, so Cypress retries the chain until the row and its button exist.

Button inside a dialog

cy.get('[data-cy="confirm-dialog"]').within(() => {
  cy.contains('button', 'Yes, Delete!').click()
})

within() makes every query in the callback search inside the dialog, avoiding a similarly labeled button elsewhere.

Scope from an existing subject

cy.get('[data-cy="profile-form"]')
  .contains('button', /^Save$/)
  .click()

Starting from a DOM query scopes contains() to that subject. Calling cy.contains() directly starts from the document body unless a within() scope is active.

6. Shadow DOM buttons

By default, cy.contains() does not cross shadow-root boundaries. Request traversal for the query:

cy.contains('button', 'Checkout', { includeShadowDom: true }).click()

Or scope to a known host and enter its shadow root:

cy.get('checkout-shell')
  .shadow()
  .contains('button', 'Checkout')
  .click()

You can also enable shadow-DOM traversal globally in Cypress configuration when that matches the application.

7. Submit inputs and non-button controls

When the control is an input[type="submit"], Cypress matches its value attribute:

cy.contains('input[type="submit"]', 'Send').click()

Set the value explicitly in your application. If the value is omitted, the browser’s default label can vary by locale.

If the control is a link styled as a button, query its actual element:

cy.contains('a', 'Continue').click()

For an accessible custom control, prefer the element’s semantic role and accessible name when your test stack provides that query.

8. Text queries versus stable selectors

Approach Use it when Tradeoff
cy.contains('button', 'Save') The label matters and substring matching is acceptable. Can match a longer label containing the phrase.
cy.contains('button', /^Save$/) The exact visible wording is behavior under test. Copy or whitespace changes can require an update.
[data-cy="save"] Element identity must survive copy changes or localization. Does not verify the user-facing label.
Role-based query such as findByRole The test should use the accessible role and name. Requires Cypress Testing Library.

Cypress recommends stable data-* attributes when wording can change, and its best-practices guidance discusses Cypress Testing Library role-based methods. Use text when the copy itself is important; use a stable selector when identity is the requirement. See Cypress best practices and the Cypress introduction.

9. Multiple matches, filtering, and negation

cy.contains() yields at most one element. It is not a collection query, so an assertion expecting multiple results will fail. Start with cy.get() when you need to inspect or filter a set:

cy.get('button')
  .filter(':contains("Save")')
  .should('have.length', 2)

For a case-sensitive exclusion, Cypress documents using jQuery’s :contains selector with .not():

cy.get('button')
  .not(':contains("Save")')
  .should('have.length.at.least', 1)

There is no built-in negation option on cy.contains().

10. Localization and changing copy

Visible-text selectors couple a test to translated or edited copy. Keep the text query when the test is explicitly checking that wording. Otherwise, add a stable attribute:

<button data-cy="save-profile">Save</button>
cy.get('[data-cy="save-profile"]').click()

You can test both concerns separately:

cy.get('[data-cy="save-profile"]')
  .should('be.visible')
  .and('contain.text', 'Save')
  .click()

11. Complete runnable example

This example covers exact matching, visibility, scoping, and the post-click result:

describe('account settings', () => {
  beforeEach(() => {
    cy.visit('/settings')
  })

  it('saves the profile with the exact button label', () => {
    cy.get('[data-cy="profile-form"]').within(() => {
      cy.contains('button', /^Save$/)
        .should('be.visible')
        .click()
    })

    cy.contains('Profile saved')
      .should('be.visible')
  })

  it('finds a case-insensitive label', () => {
    cy.contains('button', 'save', { matchCase: false })
      .should('be.visible')
      .click()
  })
})

12. Troubleshooting

Symptom Likely cause Fix
“Expected to find content…” The button has not rendered, the text differs, or the query is scoped incorrectly. Confirm the rendered text, use the correct container, and wait on a meaningful state. Increase the query timeout only when the page is legitimately slow.
The wrong control is clicked A substring match or an unscoped query found another element. Use cy.contains('button', /^Label$/) and scope with within() or a row selector.
The element exists but is not clickable The match is hidden, covered, disabled, or outside the user’s visible state. Add .should('be.visible'), assert enabled state where relevant, and fix the application state or overlay.
Text appears in a shadow component The query does not cross the shadow root. Pass includeShadowDom: true or use .shadow().contains().
Exact matching fails unexpectedly Whitespace, punctuation, or nested text differs from the expression. Inspect the rendered DOM and use a whitespace-tolerant expression such as /^\s*Save\s*$/.
Submit input is not found The visible label is controlled by the value attribute. Set an explicit value and query input[type="submit"].
Test breaks after translation The selector depends on a localized string. Use a stable data-* selector and test translated copy separately.
Case option throws an error matchCase: true conflicts with an i regular-expression flag. Choose one case-sensitivity mechanism.

13. Performance and reliability

  • Scope queries early with a form, row, or dialog to reduce ambiguity and make retries more targeted.
  • Prefer state-based assertions such as a visible success message over fixed cy.wait() delays.
  • Use exact regular expressions only when exact copy is intentional; otherwise, a stable attribute is less sensitive to editorial changes.
  • Keep selectors tied to user behavior: visible text for copy behavior, semantic roles for accessibility behavior, and data-* attributes for implementation-stable identity.
  • Set a per-command timeout for a known slow operation instead of raising the global timeout for every test.

14. Or skip the browser setup

If your next step is capturing the page for a visual check, documentation artifact, or agent workflow, ScreenshotNeo returns a screenshot or PDF with one GET request. The API can remove cookie banners, newsletter popups, and chat widgets before capture; bot checks, blank pages, failed loads, timeouts, and cache hits are not billed; and its MCP server lets AI agents take screenshots.

A capture service can remove common overlays before producing the screenshot.
A capture service can remove common overlays before producing the screenshot.

See the ScreenshotNeo API documentation for all options.

cURL

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp

Python

import requests

r = requests.get(
    "https://api.screenshotneo.com/v1/shot",
    params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"},
    timeout=90,
)
r.raise_for_status()
open("shot.webp", "wb").write(r.content)

Node.js

const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot failed: ${res.status}`);
const fs = await import('node:fs/promises');
await fs.writeFile('shot.webp', Buffer.from(await res.arrayBuffer()));

ScreenshotNeo includes full-page and element capture, device presets, custom viewports, dark mode, waits, request blocking, headers, cookies, geolocation, JavaScript, CSS, caching, signed links, asynchronous jobs, bulk capture, PDFs, and usage reporting. Responses identify the page verdict and billing status with X-Page-Verdict and X-Billed headers. One thousand screenshots per month are free with no card; paid plans start at $5 for 3,000. Create a free ScreenshotNeo account.

15. FAQ

Does cy.contains() require a button selector?

No. cy.contains('Save') is valid, but adding 'button' makes the intended element type explicit and avoids unrelated containers.

Does Cypress match hidden buttons?

It can yield a hidden match. Add .should('be.visible') when visibility matters to the test.

How do I click every button with the same text?

Use a collection query such as cy.get('button').filter(':contains("Save")'), then assert or iterate over that collection.

Should I use text or data-cy?

Use text when the label is the behavior under test. Use a stable attribute when copy, branding, or localization can change independently of the control’s identity.

Why does a plain string match a longer label?

Strings are substring matches. Use an anchored expression such as /^Save$/ for an exact label.