How to Work with Shadow DOM in Cypress Tests
Learn when to use Cypress .shadow() or includeShadowDom, how to query and interact with shadow-root elements, and how to diagnose common failures.
To query an element inside a specific Shadow DOM in Cypress, select its host and chain .shadow() before querying inside the root. For a query that should search shadow roots more broadly, use { includeShadowDom: true } on that query or configure Cypress globally. The global default is false.
Use explicit .shadow() traversal when the component boundary matters to the test. It makes the host and root visible in the command chain. Use includeShadowDom when broad traversal is an intentional convention. Cypress does not automatically search through shadow boundaries with the default setting.
1. Enter a shadow root with .shadow()
Start with the custom element or other DOM element that hosts the shadow root, then enter that root and query its contents:
cy.get('checkout-panel')
.shadow()
.find('button')
.click()
.shadow() yields the host’s shadow root. It must receive a DOM element that is itself a shadow host; it cannot be called directly from cy or after a command that yields something other than a DOM element. Once inside the root, chain queries and assertions as usual:
cy.get('checkout-panel')
.shadow()
.find('[data-cy="submit-order"]')
.should('be.visible')
.and('be.enabled')
.click()
You can also use contains() within the explicitly selected root:
cy.get('checkout-panel')
.shadow()
.contains('button', 'Place order')
.click()
This approach is useful when a page has multiple components with similar internal selectors. The chain says which host is being tested and limits the query to that component’s root.
2. Choose between explicit traversal and includeShadowDom
| Approach | Scope | Use it when |
|---|---|---|
.shadow() |
One explicitly selected host and its root | The test should show which component boundary it crosses |
includeShadowDom on a query |
That query can traverse shadow boundaries | One query should search across roots |
Global includeShadowDom |
Queries throughout the project | Broad traversal is a deliberate project-wide convention |
For a single query, pass the option to cy.get():
cy.get('.shadow-button', { includeShadowDom: true })
.should('be.visible')
.click()
The global configuration option is named includeShadowDom and defaults to false. Set it in the Cypress configuration file if the whole project intentionally wants queries to include shadow DOM. Check the configuration format used by your installed Cypress version and place the option in its supported configuration object. The global setting affects query behavior throughout the project, so use it only if that wider scope is intended.
Enabling the option changes where queries search; it does not remove the application’s shadow boundary. If only one component needs traversal, explicit .shadow() keeps that scope clear.
3. Understand how Cypress queries behave at the boundary
cy.contains()does not search inside shadow roots by default. It can opt in with{ includeShadowDom: true }, or be chained from.shadow()to search one selected root..find()does not cross a shadow boundary with the default setting. If its subject is already inside the root, it searches that tree normally.- Use
.shadow()to enter one root deliberately; useincludeShadowDomwhen a broader query is intended.
For example, to find text in one named component, scope contains() after entering its root:
cy.get('account-menu')
.shadow()
.contains('a', 'Sign out')
.click()
To allow the query to search across roots instead:
cy.contains('Sign out', { includeShadowDom: true })
.click()
Use the second form only when broad traversal is appropriate for that test. If the same text exists in more than one root, scope the query to a host or make the selector more specific.
4. Handle nested components and asynchronous rendering
A component inside another component’s shadow root introduces another boundary. Enter each host’s root before querying the next host:
cy.get('settings-panel')
.shadow()
.find('profile-card')
.shadow()
.find('[data-cy="edit-profile"]')
.click()
This assumes profile-card is a shadow host inside settings-panel‘s root. If the nested element is ordinary light DOM, do not call .shadow() on it.
Cypress retries .shadow() while waiting for the host and its root, and retries chained assertions according to Cypress’s query behavior. If a component attaches its root after rendering, the command can wait for it, subject to the command timeout. Prefer a stable host selector and a meaningful assertion over fixed sleeps when possible.
5. Troubleshoot common failures
| Symptom | Likely cause | What to check or change |
|---|---|---|
.shadow() reports that the subject is not a shadow host |
The selected element is not the host, or an earlier command yielded a non-DOM value | Verify the selector targets the component host itself, then chain .shadow() directly from that DOM element. |
The host is found, but .shadow() times out |
The component has not attached a shadow root before the timeout, or the selector matched the wrong element | Confirm the host selector and component lifecycle. Check that the root is created, and adjust the applicable Cypress timeout only if the component legitimately needs more time. |
find() or contains() cannot find an internal element |
The query is outside the root, or shadow traversal is not enabled | Chain from .shadow() for one host, or pass includeShadowDom: true for the intended broad query. |
| A broad query finds multiple matches | More than one shadow root contains a matching element | Scope to the host with .shadow() or use a more specific selector. |
click() sometimes targets the wrong element in Chrome |
Cypress documents a Chrome clicking ambiguity for a shadow-root case | The documentation’s example uses .click('top') as a workaround for that case. Treat it as a documented example, not a universal fix; verify the target and click position in the failing test. |
When a query fails, inspect the chain in this order: does the host selector match the intended DOM element; does that element actually host a shadow root; is the descendant query chained after entering the root; and does the root appear before the applicable timeout? Cypress’s retry behavior can help with delayed rendering, but it cannot make a selector target the correct host.
6. Performance, reliability, and maintenance
Choose query scope for clarity and predictable results. A selector scoped through one host documents the component under test and avoids relying on a match elsewhere in the page. A local includeShadowDom option is useful for an intentional cross-root search without changing all queries. A global setting is convenient when broad traversal is a project convention, but it also widens query behavior throughout the suite.
Use stable component host selectors and stable internal test attributes where the application provides them. Avoid adding arbitrary delays to compensate for an uncertain root lifecycle; Cypress retries the query and chained assertions until they pass or time out. If a timeout is appropriate, keep it specific to the slow command or test condition rather than raising every command timeout without evidence.
Shadow DOM support also appears in Cypress UI Coverage, which can identify interactive elements inside shadow DOM and qualify their identities with the host chain. That is coverage reporting behavior; it is separate from selectors and traversal in test code.
Or skip the browser setup
If your task is to capture a page screenshot while debugging a visual issue, ScreenshotNeo provides a screenshot API and MCP server. Cypress tests still make sense when you need assertions and interaction; for a screenshot alone, one API request can return an image or PDF.
See the ScreenshotNeo API documentation. This cURL example saves a WebP screenshot:
curl -G "https://api.screenshotneo.com/v1/shot" \
-d access_key=YOUR_API_KEY \
--data-urlencode url=https://stripe.com \
-o shot.webp
Equivalent Python:
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)
Equivalent 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}`);
const bytes = new Uint8Array(await res.arrayBuffer());
await import('node:fs/promises').then(fs => fs.writeFile('shot.webp', bytes));
- Cookie banners, newsletter popups, and chat widgets are removed before the shot; each cleanup step can be turned off.
- Bot checks, blank pages, failed loads, timeouts, and cache hits are not billed, and response headers report the page verdict and billing status.
- An MCP server lets AI agents use screenshot, page information, and PDF capture tools.
- 1,000 screenshots a month are free with no card. Paid plans start at $5 for 3,000 shots; every feature is on every plan.
Sign up for ScreenshotNeo’s free plan to get 1,000 screenshots a month with no card.
FAQ
Does Cypress search Shadow DOM automatically?
No. The documented global default for includeShadowDom is false. Enter a particular root with .shadow() or opt a query into broader traversal.
Can I use .shadow() on any element?
No. Its subject must be a DOM element that directly hosts a shadow root.
Is Cypress UI Coverage the same as Shadow DOM query support?
No. UI Coverage recognizes shadow-DOM elements for coverage reporting. Test queries still need the appropriate traversal or inclusion setting.
Where should I confirm the behavior for my project?
Check the Cypress API and configuration documentation for the version installed in your project, since APIs and defaults can change.


