In Cypress, how do you click a button that lives inside a component's open shadow root?
answer
- The default is not to look inside
- One hop, chained off the host
- A per-query option with the same name
- It is also a config key, default false
- Closed roots stay out of reach
basics
~10 sChain .shadow() off the host element and keep traversing: cy.get('room-card-widget').shadow().find('.book-btn').click(). Alternatively pass { includeShadowDom: true } to that one query, or set the includeShadowDom config value, which defaults to false.
solid answer
~40 sCypress does not cross a shadow boundary unless you ask it to. The explicit route is `.shadow()`, a query chained off the host element that yields its shadow root: `cy.get('room-card-widget').shadow().find('.book-btn').click()`. It retries until the subject exists and hosts a root, and otherwise fails with "Expected the subject to host a shadow root, but never found it". A narrower alternative is the per-query option, `cy.get('.book-btn', { includeShadowDom: true })`, which `cy.get()`, `.find()` and `.contains()` all accept. The same name is a config key, default `false`, overridable per suite or test; switching it on globally makes every query search every open shadow root on the page. Closed roots stay unreachable — the platform hides them from script.
code
javascript · 9 lines// explicit hop through the host's shadow root
cy.get('room-card-widget').shadow().find('.book-btn').click()
// same reach, per query, without naming the host
cy.get('.book-btn', { includeShadowDom: true }).click()
cy.contains('Deluxe King', { includeShadowDom: true })
// Chrome sometimes resolves the click point through the root:
cy.get('room-card-widget').shadow().find('.book-btn').click('top')go deeper
Know that Cypress stops at a shadow boundary by default, and that .shadow() chained off the host element is how a test steps across it.
Explain the three routes across — .shadow(), the per-query option and the config key — and what each one costs in reach.
Diagnose a query that finds nothing on an element you can see, and decide between an explicit hop and a spec-level override.
Own whether a suite crosses component boundaries at all, and what that says about testing components through their public surface.
## Cypress does not cross a shadow boundary by default A shadow root is a separate DOM tree attached to a host element; the browser's ordinary `querySelectorAll` from the document does not see inside it. That encapsulation is the platform's behaviour, owned by the web-components side of the world, and Cypress inherits it: as of Cypress 16 `includeShadowDom` defaults to `false`, so `cy.get('.book-btn')` will not match a button that lives inside a `<room-card-widget>` element's shadow root — even though the button is visible, clickable and plainly there in the inspector. Cypress gives you three ways across, in increasing order of blast radius. ## 1. `.shadow()` — the explicit hop `.shadow()` is a query chained off the **host** element. It yields the host's shadow root, from which `.find()` works normally: ```javascript cy.get('room-card-widget').shadow().find('.book-btn').click() ``` Because it is a query it retries, honours `defaultCommandTimeout`, and is safe to chain further commands onto. It retries until the subject exists *and* hosts a shadow root; if the element never gets one, the failure is explicit: > Expected the subject to host a shadow root, but never found it. That message is the point: the hop is written down, so a reader of the spec can see exactly where the test leaves the light DOM. ## 2. The per-query option `cy.get()`, `.find()` and `.contains()` each accept `{ includeShadowDom: true }`, which lets that one query match across boundaries without naming the host: ```javascript cy.get('.book-btn', { includeShadowDom: true }).click() ``` Useful when the host is an implementation detail you would rather not hard-code, or when a component nests shadow roots several deep and spelling out each hop is noise. ## 3. The config flag `includeShadowDom` is also a configuration key, and it is one of the values that can be overridden per suite or per test as well as set globally. Turning it on globally makes **every** query in the run search shadow roots. | approach | scope of the change | reads as | |---|---|---| | `.shadow()` | one chain | "we are entering this component's shadow root" | | `{ includeShadowDom: true }` | one query | "this lookup may cross a boundary" | | test or suite override | one file or block | "this spec is about a shadow-DOM component" | | global config | the whole run | "boundaries are invisible everywhere" | The global setting is the one to think twice about: - Every query walks all open shadow roots reachable from its root, so a page with many custom elements does more work on **every retry**, not just once. - A selector that was unambiguous in the light DOM can start matching an element inside a component, which turns an encapsulation change into a test failure with no obvious cause. - The suite loses the signal that a test deliberately reaches inside a component. Per-test or per-suite overrides are the usual middle ground: the component's own specs opt in, and the rest of the run keeps the boundary. ## Closed roots, and the click quirk Two limits are worth stating out loud: 1. **Closed roots are unreachable.** Cypress finds shadow roots by reading the host's `shadowRoot` property, which the platform leaves `null` for a root attached in closed mode. No Cypress option changes that; the fix belongs in the component. 2. **Clicking inside a shadow root can hit the wrong element in Chrome**, because of a specification ambiguity in how `elementFromPoint` resolves through a shadow root. The documented workaround is to pass a position: `cy.get('room-card-widget').shadow().find('.book-btn').click('top')`. ## Nested roots, and what `.shadow()` needs as a subject `.shadow()` requires its subject to be a host — an element with a shadow root **directly** attached. It does not search for one: - `cy.get('.book-btn').shadow()` fails when the button is an ordinary element, because a button that merely lives inside a shadow root does not itself host one. - A component that composes other custom elements produces nested roots, and each boundary is its own hop: `cy.get('room-card-widget').shadow().find('price-tag').shadow().find('.amount')`. - Chained hops stay readable for two levels and stop being readable at four, which is the point at which the per-query option earns its keep. Also remember that everything after the hop is ordinary traversal. Once `.shadow()` has yielded the root, `.find()`, `.filter()`, `.eq()` and the rest behave exactly as they do in the light DOM, because they are operating inside a single tree again. ## Choosing - Reaching into one known component: `.shadow()`. - One selector that must cross without naming a host: the per-query option. - A whole spec about a shadow-DOM component: a suite or test override. - The whole suite: rarely worth it, and worth revisiting if you inherited it switched on.
- What are the costs of setting Cypress's `includeShadowDom` to `true` for a whole run?Every query then walks all open shadow roots reachable from its root, so a page of custom elements does more work on each retry, not once. Selectors that were unambiguous in the light DOM can start matching inside components, turning an encapsulation change into an unexplained failure, and the suite loses the signal that a test deliberately reached inside one. Prefer a suite or test override.
- Why can Cypress not reach into a shadow root attached in closed mode?Cypress locates shadow roots by reading the host element's `shadowRoot` property, and the DOM specification leaves that property `null` for a root attached in closed mode — the same restriction any script on the page faces. No Cypress option or config key changes it, so the remedy is for the component to expose an open root or a testable surface.
saying these in an interview costs you the question
- Thinks cy.get() searches shadow roots by default
- Tries to start a chain with .shadow() instead of chaining it off a host
- Believes includeShadowDom opens closed shadow roots too
- Turns includeShadowDom on globally without weighing the cost
- Expects .shadow() to yield the host element instead of its root