On this page
Declarative Shadow DOM: The Parts That Only Show Up After You Ship It
What every writeup about Declarative Shadow DOM misses - parser eligibility rules, streaming serialization edges, adoptedStyleSheets timing, and the SSR quirks that only appear after you ship.
Every writeup of Declarative Shadow DOM (DSD) uses the same example: a custom element, a <template shadowrootmode="open"> inside it, a <slot>, done. That example is correct. It will not prepare you for anything below.
DSD is a parser feature, not a template feature. The spec text lives in the HTML parsing algorithm, in the tree construction rules for the <template> start tag, not in some standalone chapter about how shadow DOM works in HTML. Once that clicks, most of the behavior below stops being surprising and starts being obviously how a parser would act.
Your host element has to be on a short list, and nothing tells you when it isn’t
attachShadow() only works on a fixed set of elements. Everything else throws, or in the declarative case, silently does nothing.
| Can be a shadow host | Cannot be a shadow host |
|---|---|
article, aside, blockquote, body, div, footer, h1–h6, header, main, nav, p, section, span | button, a, img, input, select, table, and any other element not on the left |
| Any autonomous custom element with a valid hyphenated name | Customized built-ins based on an ineligible element (table is="data-grid", button is="fancy-button"), even though they’re custom elements |
Call attachShadow() on an ineligible element and you get a NotSupportedError. The declarative path is quieter and more dangerous: the parser checks the same eligibility rule, and if the host fails it, the parser stops there. No exception, no console message. The <template> just stays a plain, non-rendering template element, and whatever you meant to show never appears.
This shows up most with customized built-ins. <table is="data-grid"> compiles, passes review, and renders an empty grid, because table was never eligible in the first place, is or not.
<!-- Silently does nothing. No shadow root, no error. -->
<table is="data-grid">
<template shadowrootmode="open">
<style>/* never applied - there is no shadow tree to scope it to */</style>
<slot></slot>
</template>
<tr><td>1</td><td>2</td></tr>
</table>
<!-- Works: wrap the eligible element, put the ineligible one inside it. -->
<div is="data-grid-wrapper">
<template shadowrootmode="open">
<style>::slotted(table) { border-collapse: collapse; }</style>
<slot></slot>
</template>
<table><tr><td>1</td><td>2</td></tr></table>
</div>
If you maintain a component library, fail loud instead of trusting that the markup compiled cleanly:
class DataGrid extends HTMLElement {
connectedCallback() {
if (!this.shadowRoot) {
console.error(`<${this.tagName.toLowerCase()}> is not a valid shadow host - DSD silently no-opped`);
}
}
}
Only the first shadowrootmode template under a host counts
A second <template shadowrootmode> under the same host doesn’t merge with the first one and doesn’t error. It parses as an ordinary, inert template element. MDN mentions this rule in passing. What’s missing everywhere is what actually produces the duplicate, and what the leftover node does to your app.
Common real-world causes:
- Streaming SSR retrying a chunk after a timeout and re-emitting the same fragment
- A templating layer including a partial twice because of a loop bug
- React Server Components re-flushing a Suspense boundary
- A build step concatenating a component’s output with itself by accident
Whatever the cause, you end up with this:
<my-card>
<template shadowrootmode="open"><!-- becomes the shadow root --></template>
<template shadowrootmode="open"><!-- stays a literal template node --></template>
</my-card>
The leftover template is real and queryable. Two things follow from that:
document.querySelectorAll('template')now returns a node your code didn’t expect. Any generic leftover-from-before-DSD template-hydration logic will try to process it.- Any hydration matching that assumes one child template per host will grab the wrong one, or throw when
.contentunexpectedly holds a full fragment nobody asked for.
Detect duplicates explicitly instead of assuming a clean SSR pipeline:
function assertNoDuplicateShadowTemplates(root = document) {
for (const host of root.querySelectorAll('*')) {
const leftover = [...host.children].filter(
c => c.tagName === 'TEMPLATE' && c.hasAttribute('shadowrootmode')
);
if (leftover.length && host.shadowRoot) {
console.warn('Duplicate declarative shadow root template found on', host, leftover);
}
}
}
attachShadow() on top of a declarative root only works if the mode matches
Hybrid components, server-rendered with DSD and then claimed by client-side JS for interactivity, need to check for an existing shadow root before calling attachShadow(). Web.dev’s own example does this with ElementInternals.shadowRoot. What’s easy to miss is exactly what happens if that check is skipped.
| Existing declarative root | Client calls attachShadow() with | Result |
|---|---|---|
| open | open | Root is cleared and reused |
| closed | closed | Root is cleared and reused |
| open | closed | Throws NotSupportedError |
| closed | open | Throws NotSupportedError |
| none | either | Ordinary attach, no issue |
The third and fourth rows are the trap for teams that build client-only first and add SSR later. A custom element’s constructor calls attachShadow({ mode: 'closed' }) unconditionally, and it works fine in development because there’s never a pre-existing root to conflict with. SSR ships, the DSD templates happen to say shadowrootmode="open" (copied from a tutorial, or the default of whatever generates the markup), and every instance now throws inside the constructor on the real page.
Constructor errors on custom elements don’t bubble up loudly. The element fails to upgrade, connectedCallback never fires, and the bug report reads as the component sometimes doesn’t work, which is the hardest kind to track down.
The fix is the same defensive check web.dev already shows, written so a later refactor can’t remove the safety it depends on:
class MenuToggle extends HTMLElement {
constructor() {
super();
const internals = this.attachInternals();
// Always check for a pre-existing (declarative) root first.
// Calling attachShadow unconditionally throws if a DSD root with
// a different mode already exists.
this._shadow = internals.shadowRoot ?? this.attachShadow({ mode: 'open' });
}
}
If the SSR template generator and the client bundle aren’t built from one shared source of truth for the mode, pin it in one place and import it into both.
cloneNode() drops your shadow tree unless you opt in
Declarative shadow roots briefly defaulted to clonable during the spec’s development, then that default was reverted. shadowrootclonable now defaults to false, same as the imperative option. This matters because cloning one server-rendered node to produce a list, instead of re-parsing HTML strings a thousand times, is a normal client-side optimization.
// The clone will NOT include the shadow tree unless the original
// template had shadowrootclonable set.
const copy = document.querySelector('my-card').cloneNode(true);
copy.shadowRoot; // null
The flag has to be on the template at render time. HTMLTemplateElement.shadowRootMode and its siblings aren’t settable after parsing, so there’s no fixing this after the fact:
<my-card>
<template shadowrootmode="open" shadowrootclonable>
<slot></slot>
</template>
</my-card>
If a server-side templating function generates DSD markup, clonability needs to be a parameter of that function, not an attribute someone remembers to add by hand on the one component that ends up getting cloned.
innerHTML never produces a declarative shadow root, on read or write
This is the costliest one to debug, because it looks like it should just work, given how consistently every other HTML feature behaves whether you parse it through the page or through a string.
Declarative shadow roots are created only by the main HTML parser, plus two explicit opt-in APIs: setHTMLUnsafe() and Document.parseHTMLUnsafe(). Assigning to .innerHTML, calling insertAdjacentHTML(), or using DOMParser().parseFromString() all parse the shadowrootmode attribute back out as an ordinary, inert template. No shadow root, no error, no console message.
This is deliberate. Sanitizers that scrub HTML strings for XSS generally don’t traverse shadow trees. If fragment-parsing APIs honored DSD by default, a string that looked sanitized could smuggle a live shadow root full of unsanitized markup straight past the sanitizer.
const html = `<div><template shadowrootmode="open"><script>alert(1)</script></template></div>`;
const a = document.createElement('div');
a.innerHTML = html;
a.firstElementChild.shadowRoot; // null - parsed as an ordinary template
const b = document.createElement('div');
b.setHTMLUnsafe(html);
b.firstElementChild.shadowRoot; // ShadowRoot - this is the opt-in path
The part nobody mentions: this doesn’t just affect code you write directly. Any DOM-diffing or morphing library that patches a subtree by assigning innerHTML, still the mechanism behind a lot of out-of-band swap logic, including some htmx swap strategies and no-build-step partial-render helpers, will silently downgrade any declarative shadow root inside that subtree back into an inert template on the next patch. A component is interactive after the first page load and dead after the first partial update, with nothing in the console to explain why.
Before building anything DSD-based that needs to survive a re-render, check whether the patching library’s swap mechanism has adopted setHTMLUnsafe.
CSP nonces and shadow-root style deduplication work against each other
DSD’s SSR pitch usually mentions that browsers deduplicate identical <style> content across shadow roots, so a repeated component doesn’t get reparsed a thousand times. What’s missing is that this deduplication is a literal text match, and CSP nonces are per-element attributes with no special shadow-DOM scoping.
| Setup | Result |
|---|---|
| Same nonce on every style tag, identical CSS text in each shadow root | CSP passes; browser deduplicates to one parsed stylesheet |
| Unique nonce per shadow-root instance | CSP rejects every mismatched nonce, often invisible in dev since CSP is frequently disabled there; dedup also breaks, since the serialized style content is no longer identical |
If a page uses style-src 'nonce-xxxxx', every <style> tag needs that same nonce, including every one inside every declarative shadow root, because a page gets one nonce per response. The mistake people actually make is assuming the opposite: that shadow DOM’s style isolation means each instance should get its own nonce, since the styles are isolated from each other anyway. They’re isolated from the page’s cascade, not from CSP, and CSP has no concept of shadow boundaries at all.
<!-- Wrong: unique nonce per instance breaks CSP and kills stylesheet dedup -->
<product-card>
<template shadowrootmode="open">
<style nonce="a1b2c3">.price { color: green; }</style>
</template>
</product-card>
<product-card>
<template shadowrootmode="open">
<style nonce="d4e5f6">.price { color: green; }</style>
</template>
</product-card>
<!-- Right: same page nonce everywhere, identical style content, dedup applies -->
<product-card>
<template shadowrootmode="open">
<style nonce="a1b2c3">.price { color: green; }</style>
</template>
</product-card>
<product-card>
<template shadowrootmode="open">
<style nonce="a1b2c3">.price { color: green; }</style>
</template>
</product-card>
For a handful of components on a page, the difference is invisible. For a product grid or data table with a few hundred server-rendered rows, each with its own shadow root and style block, one parsed stylesheet versus a few hundred separately parsed ones is a measurable chunk of main-thread time that a ten-item demo will never surface.
outerHTML never serializes shadow content, declarative or not
Caching rendered HTML, whether that means edge caching a hydrated fragment or snapshotting a component to store its current state, tends to reach for element.outerHTML. That has never included shadow tree content, imperative or declarative. It’s easy to miss because DSD makes shadow content look like it’s just part of the HTML the first time the page loads. It is, at parse time. It stops being true the moment you serialize the live DOM back to a string afterward.
Round-tripping needs getHTML() (or getInnerHTML()) plus shadowrootserializable on the template at creation time:
<user-card>
<template shadowrootmode="open" shadowrootserializable>
<style>.name { font-weight: bold; }</style>
<span class="name"><slot></slot></span>
</template>
Ada Lovelace
</user-card>
const el = document.querySelector('user-card');
el.outerHTML; // shadow content missing
el.getHTML({ serializableShadowRoots: true }); // shadow content included
Without shadowrootserializable set on the original template, getHTML() won’t include that root’s contents either. Like shadowrootclonable, it can’t be added after parsing.
Manual slot assignment can’t be finished declaratively
shadowrootslotassignment mirrors the slotAssignment option of attachShadow(): named (the default) or manual. Manual assignment exists to decouple visual order from light-DOM and accessibility-tree order, letting a layout reorder how children appear without reordering the underlying markup.
None of that finishes declaratively. Setting shadowrootslotassignment="manual" produces a shadow root in manual mode with nothing assigned to any slot, because there’s no HTML syntax for assigning a specific light-DOM child to a specific slot. That assignment only exists as HTMLSlotElement.assign(), called from script, after the shadow root already exists.
<reorder-widget>
<template shadowrootmode="open" shadowrootslotassignment="manual">
<slot name="second"></slot>
<slot name="first"></slot>
</template>
<p id="a">First in the markup</p>
<p id="b">Second in the markup</p>
</reorder-widget>
// Nothing renders in either slot until this runs - there is no
// declarative way to assign #a to the first slot.
const host = document.querySelector('reorder-widget');
const slots = host.shadowRoot.querySelectorAll('slot');
slots[0].assign(host.querySelector('#b'));
slots[1].assign(host.querySelector('#a'));
If the goal is genuinely zero JavaScript, manual slot assignment is off the table, and named assignment, the default, is what’s left. That’s fine for most layouts, but worth knowing before designing one around a feature that can’t be completed without a script tag.
The moment the shadow root attaches changed, and older writeups describe the old moment
Early descriptions of DSD, including the original explainer text, describe the shadow root as attaching when the closing </template> tag is reached, after its content finishes parsing. The current, shipped behavior, needed to support streaming, attaches the shadow root at the opening tag instead, with content parsed directly into it as it streams in. This is a real spec and implementation change, not a documentation inconsistency, so a blog post from 2022 or 2023 and current Chrome, Firefox, and Safari behavior describe two different moments.
<div id="el">
<script>
console.log(el.shadowRoot); // null - template hasn't been reached yet
</script>
<template shadowrootmode="open">
<p>content streamed in here, potentially across multiple response chunks</p>
</template>
<script>
console.log(el.shadowRoot); // ShadowRoot object, now populated
</script>
</div>
The practical trap: inline scripts placed between the opening tag and the closing tag, written under the older closing-tag mental model, can now observe a shadow root that exists but is only partially populated on a slow connection. In practice this only matters for unusual cases, like reading shadowRoot.children.length from a script nested inside the same template’s content. If a hydration-signaling pattern was copied from a pre-2023 article, verify it against current behavior rather than assuming the timing model in that post still holds.
Foreign content breaks the mechanism quietly
The DSD attachment logic lives inside the HTML parser’s specific handling of the <template> start tag within the ordinary HTML insertion modes. Foreign content, meaning anything inside <svg> or <math>, is parsed under different insertion-mode rules that don’t run those branches. A <template shadowrootmode> written inside an <svg> subtree is parsed as a foreign, inert node. The shadowrootmode attribute has no special meaning there, and no shadow root attaches.
For an SVG-based icon or diagram component that wants shadow encapsulation, the shadow host needs to be in the HTML namespace, with the <svg> living inside its light DOM or slotted content, rather than making the <svg> element itself the host.
<!-- Does not work: template is parsed as foreign content, not DSD -->
<svg>
<template shadowrootmode="open">...</template>
</svg>
<!-- Works: the shadow host is an ordinary HTML element, the svg lives inside it -->
<icon-badge>
<template shadowrootmode="open">
<style>svg { width: 1em; height: 1em; }</style>
<slot></slot>
</template>
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/></svg>
</icon-badge>
None of the nine issues above are exotic. They surface once a DSD-based component library has been running in front of real traffic for a while: a CSP rollout, a caching layer, a virtualization pass, a table-shaped custom element someone builds because the design system needed one, a streaming SSR retry under load. The spec text for all of them was there from the start. It’s just written as parser algorithm steps and DOM exceptions, not as prose aimed at someone shipping a product.
From the team at
We build digital products and explore the modern web standards behind them.