How to Access Modal Dialogs in Cypress
Learn how to test DOM modals, alert, confirm, prompt, and iframe dialogs in Cypress with reliable selectors, events, stubs, and fixes.
Use ordinary Cypress DOM commands for application-rendered modals: trigger the UI, query the dialog by a stable selector or accessible name, assert that it is visible, interact with its controls, and assert the resulting state. Native browser dialogs use Cypress window events, while same-origin iframe dialogs require querying the iframe document and wrapping it back into Cypress.
1. Choose the right modal technique
| Dialog type | Cypress approach | Key detail |
|---|---|---|
| DOM-rendered modal | cy.get(), .find(), .should(), .click() |
Use stable data-* selectors or accessible names. |
alert() |
cy.on('window:alert', ...) |
Cypress automatically accepts alerts; inspect the message in the handler. |
confirm() |
cy.on('window:confirm', ...) |
Confirmations are accepted by default. Return false to test dismissal. |
prompt() |
Stub window.prompt in onBeforeLoad |
Install the stub before application code can call it. |
| Same-origin iframe modal | Read contentDocument.body, wait for content, then cy.wrap() |
The iframe body must be non-empty before querying. |
| Cross-origin embedded iframe | Browser security limits access | cy.origin() does not enter an embedded cross-origin iframe. |
2. Test a regular application modal
A regular modal is HTML rendered by your application. Cypress can interact with it like any other element. Give the dialog a stable selector and an accessible role or name.
describe('delete confirmation modal', () => {
beforeEach(() => {
cy.visit('/items')
})
it('opens, confirms, and removes an item', () => {
cy.get('[data-cy="delete-item-42"]').click()
cy.get('[role="dialog"]')
.should('be.visible')
.and('have.attr', 'aria-modal', 'true')
.within(() => {
cy.contains('h2', 'Delete item?').should('be.visible')
cy.get('[data-cy="confirm-delete"]').click()
})
cy.get('[data-cy="item-42"]').should('not.exist')
cy.get('[role="dialog"]').should('not.exist')
})
})
Cypress retries queries and assertions until they pass or the command timeout expires. The visibility assertion also checks whether another element covers the target, so a covered button can fail even when it exists in the DOM.
Query by accessible name
cy.get('[data-cy="open-settings"]').click()
cy.get('[role="dialog"][aria-labelledby="settings-title"]')
.should('be.visible')
.within(() => {
cy.get('#settings-title').should('have.text', 'Settings')
cy.contains('button', 'Close').click()
})
Prefer selectors that describe behavior rather than layout classes. A data-cy attribute, dialog role, accessible heading, or explicit label is less brittle than a long CSS path.
Test keyboard and backdrop behavior
it('closes with Escape', () => {
cy.get('[data-cy="open-help"]').click()
cy.get('[role="dialog"]').should('be.visible').type('{esc}')
cy.get('[role="dialog"]').should('not.exist')
})
it('closes when the backdrop is clicked', () => {
cy.get('[data-cy="open-help"]').click()
cy.get('[data-cy="modal-backdrop"]').click('topLeft')
cy.get('[role="dialog"]').should('not.exist')
})
3. Handle native alert dialogs
Cypress automatically accepts JavaScript alert() dialogs. Register a window:alert listener before the action that opens the alert and make synchronous assertions inside the callback.
it('checks an alert message', () => {
cy.on('window:alert', (message) => {
expect(message).to.eq('Your changes were saved.')
})
cy.get('[data-cy="save"]').click()
cy.get('[data-cy="save-status"]').should('contain', 'Saved')
})
There is no accept or dismiss command for an alert. Cypress controls that behavior automatically, so test the message and the application state that follows.
4. Accept or dismiss confirm dialogs
Cypress accepts confirm() by default. Return false from a window:confirm handler to exercise the Cancel path.
it('accepts a confirmation', () => {
cy.on('window:confirm', (message) => {
expect(message).to.eq('Are you sure?')
return true
})
cy.get('[data-cy="delete"]').click()
cy.get('[data-cy="deleted-state"]').should('be.visible')
})
it('dismisses a confirmation', () => {
cy.on('window:confirm', (message) => {
expect(message).to.eq('Are you sure?')
return false
})
cy.get('[data-cy="delete"]').click()
cy.get('[data-cy="deleted-state"]').should('not.exist')
cy.get('[data-cy="item"]').should('be.visible')
})
Attach the handler before the click or other command that invokes the dialog. Keep Cypress commands out of the event callback; callbacks run outside Cypress’s normal command queue.
5. Stub prompt before the application loads
Install the browser method stub in onBeforeLoad so application code sees the stub from its first execution.
it('supplies a value to prompt()', () => {
cy.visit('/', {
onBeforeLoad(win) {
cy.stub(win, 'prompt').returns('Ada Lovelace')
},
})
cy.get('[data-cy="ask-name"]').click()
cy.get('[data-cy="greeting"]').should('contain', 'Ada Lovelace')
})
To test cancellation, return null instead:
cy.visit('/', {
onBeforeLoad(win) {
cy.stub(win, 'prompt').returns(null)
},
})
6. Access a modal inside a same-origin iframe
For a same-origin frame, obtain its document body, wait until asynchronous content has rendered, wrap the body as a Cypress subject, and continue with normal queries.
it('closes a modal in a same-origin iframe', () => {
cy.get('iframe#checkout')
.its('0.contentDocument.body')
.should('not.be.empty')
.then(cy.wrap)
.find('[role="dialog"]')
.should('be.visible')
.contains('button', 'Close')
.click()
cy.get('iframe#checkout')
.its('0.contentDocument.body')
.then(cy.wrap)
.find('[role="dialog"]')
.should('not.exist')
})
The non-empty assertion is a synchronization point for frames that render after the iframe element itself appears.
7. Understand cross-origin iframe limits
The browser same-origin policy prevents Cypress from reading a cross-origin embedded iframe’s DOM. cy.origin() handles top-level navigation between origins; it does not enter an embedded cross-origin iframe.
Cypress documents chromeWebSecurity: false as a Chromium-family workaround, but it has Firefox and WebKit limitations. Treat it as an environment-specific option, not a portable modal strategy:
// cypress.config.js
const { defineConfig } = require('cypress')
module.exports = defineConfig({
chromeWebSecurity: false,
})
When you cannot access the frame, test the integration boundary instead: assert that the iframe is present, verify messages or network requests exchanged with the embedded application, or run a separate test suite on the iframe’s own origin.
8. Use cy.prompt() only when its limits fit
The current cy.prompt() reference includes natural-language steps such as “dismiss the modal.” It is limited to end-to-end tests, Chromium-based browsers, and does not support iframes or every Cypress command area. Explicit DOM commands and dialog events remain the clearest deterministic APIs when you need precise control.
9. Troubleshoot common failures
| Failure | Likely cause | Fix |
|---|---|---|
| Element is covered | A backdrop, overlay, or stacking context is over the target. | Assert the dialog and its controls are visible; close the blocking overlay or fix z-index/layout behavior. Do not use force: true unless the real user can reach the element. |
| Element is not visible | The modal has not opened, is transitioning, or is hidden with CSS. | Assert the open state after the trigger and use a meaningful state assertion instead of a fixed sleep. |
| Confirm handler never runs | The listener was registered after the click. | Register cy.on('window:confirm', ...) before the command that invokes confirm(). |
| Prompt value is ignored | The application called prompt() during page load before the stub existed. |
Stub it in cy.visit(..., { onBeforeLoad }). |
| Cypress commands fail inside event callback | Event callbacks are outside the command queue. | Use synchronous assertions or a stub in the callback; assert resulting state after the triggering command. |
| Iframe body is empty | The frame has not finished rendering. | Chain .should('not.be.empty') before then(cy.wrap). |
| Cross-origin iframe cannot be queried | Same-origin policy blocks DOM access. | Test the frame boundary, use a separate origin suite, or evaluate the Chromium-only security setting with its browser limitations. |
| Modal selector is flaky | Selector depends on generated classes or layout. | Add stable data-cy attributes and accessible dialog names. |
10. Reliability and performance checklist
- Register native-dialog handlers before the triggering action.
- Prefer stable
data-*selectors and accessible names. - Assert that a dialog is open before interacting with its controls.
- Let Cypress retry meaningful assertions; avoid arbitrary waits.
- Keep
cy.*commands andcy.task()out ofcy.on()callbacks. - For iframe content, wait for a non-empty body and re-wrap it.
- Keep modal tests focused on one open, interaction, and resulting-state flow.
- Use shorter command timeouts only when the application has a known response budget; increase them for intentionally slow remote frames.
11. Or skip the browser setup
If you need an image of a page or modal state rather than an interactive assertion, ScreenshotNeo captures the URL with one request. Its cleanup steps accept cookie or consent banners and remove more than 60 known consent platforms, newsletter popups, and chat widgets before capture; each step can be disabled. Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed, and the response identifies the result with X-Page-Verdict and X-Billed headers. ScreenshotNeo also provides an MCP server for AI agents using Claude, Cursor, or any MCP client.
curl -G "https://api.screenshotneo.com/v1/shot" \
-d access_key=YOUR_API_KEY \
--data-urlencode url=https://stripe.com \
-o shot.webp
import requests
r = requests.get(
"https://api.screenshotneo.com/v1/shot",
params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"},
timeout=90,
)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
See the ScreenshotNeo API documentation for options such as custom JavaScript, CSS, waits, selectors, device presets, cookies, headers, and PDF output. The free plan includes 1,000 screenshots a month with no card; paid plans start at $5 for 3,000 shots.
Create a free ScreenshotNeo account.
12. FAQ
Can Cypress click a button behind a modal?
No. If an overlay covers it, Cypress treats it as unreachable, matching what a user can do. Close the modal or correct the overlay before clicking.
Does Cypress auto-accept alert and confirm?
Alerts are auto-accepted. Confirms are accepted unless a window:confirm handler returns false.
Can I use cy.origin() for an embedded iframe modal?
No. It supports top-level origin changes, not DOM access inside an embedded cross-origin frame.
Should I use cy.prompt() for every modal?
No. Use it only when its end-to-end, Chromium, and iframe limitations fit your test; explicit commands are more deterministic.
How do I make modal tests stable?
Use stable selectors, register event handlers early, assert visibility and state, wait on application conditions, and avoid fixed sleeps.


