Shadow DOM without a constructor call
Shadow DOM has always been created imperatively: a component calls attachShadow() and populates the resulting shadow root. That works fine for client-side rendering, but it leaves server-rendered HTML with no way to express a shadow root, which hurts both performance and the experience for users who receive HTML before JavaScript runs. Declarative Shadow DOM closes that gap by making shadow roots part of the HTML parser's output rather than a side effect of script execution.
A declarative shadow root is simply a <template> element carrying the shadowrootmode attribute:
<host-element>
<template shadowrootmode="open">
<slot></slot>
</template>
<h2>Light content</h2>
</host-element>
When the HTML parser encounters that template, it immediately attaches the parsed content as the shadow root of the parent element. The full DOM tree—including the shadow root—exists in static markup, with no JavaScript required.
How existing components keep working
Historically, calling attachShadow() on an element that already had a shadow root threw an error. With declarative roots, that behavior has shifted: attachShadow() on an element with an existing declarative shadow root returns the emptied declarative root instead of throwing. Older components therefore continue to function unchanged, since a declarative root is preserved until a component supplies an imperative replacement.
Newer components have a more explicit route via the ElementInternals.shadowRoot property, which exposes an element's existing shadow root—declarative or otherwise—whether open or closed. In the constructor, checking this property first lets a component reuse server-rendered shadow content and only fall back to attachShadow() when no root was provided in the HTML:
<menu-toggle>
<template shadowrootmode="open">
<button>
<slot></slot>
</button>
</template>
Open Menu
</menu-toggle>
<script>
class MenuToggle extends HTMLElement {
constructor() {
super();
const supportsDeclarative = HTMLElement.prototype.hasOwnProperty("attachInternals");
const internals = supportsDeclarative ? this.attachInternals() : undefined;
const toggle = () => {
console.log("menu toggled!");
};
// check for a Declarative Shadow Root.
let shadow = internals?.shadowRoot;
if (!shadow) {
// there wasn't one. create a new Shadow Root:
shadow = this.attachShadow({
mode: "open",
});
shadow.innerHTML = `<button><slot></slot></button>`;
}
// in either case, wire up our event listener:
shadow.firstElementChild.addEventListener("click", toggle);
}
}
customElements.define("menu-toggle", MenuToggle);
</script>
One root per element, streamed as it arrives
Each declarative shadow root is bound to its immediate parent element. That colocation keeps the parser simple and lets shadow roots stream: as the parser reads the opening <template> tag, content is parsed straight into the shadow root, which means the browser can render that subtree incrementally rather than waiting for the entire document.
Because a declarative shadow root is attached during parsing, it only exists when the shadowrootmode template is part of the initial HTML document. Parsing the same markup later—via innerHTML or insertAdjacentHTML()—does nothing; those fragment-parsing APIs are excluded for security reasons. The only script-based paths that honor declarative shadow roots are setHTMLUnsafe() and parseHTMLUnsafe().
Styling server-rendered shadow content
Both inline <style> blocks and external <link> stylesheets are valid inside declarative shadow roots. The browser also deduplicates repeated stylesheets: if identical style content appears in multiple declarative roots, it is parsed once and backed by a single shared CSSStyleSheet instance. Constructable stylesheets are not supported here, since they cannot be serialized into HTML and there is no syntax for referencing them from a declarative root.
Before declarative shadow DOM, a common defense against flash-of-unstyled-content was hiding any custom element until it had been upgraded:
<style>
x-foo:not(:defined) > * {
display: none;
}
</style>
That approach no longer works when declarative shadow content arrives in the HTML, because it would hide the very content meant to be visible before JavaScript loads. The solution is to target the template element instead of the custom element: browsers that lack declarative shadow DOM support keep that template in the DOM, so it can be used as a marker to hide children until the polyfill has converted the template into a real shadow root:
<style>
x-foo:not(:defined) > template[shadowrootmode] ~ * {
display: none;
}
</style>
In supporting browsers, the template is removed during parsing and the rule never applies; in non-supporting browsers, children remain hidden until the component implementation is ready.
Detecting Declarative Shadow DOM support
Declarative Shadow DOM has been available in Chrome 90 and Edge 91, but those early versions relied on a non-standard shadowroot attribute. The standardized shadowrootmode attribute, along with streaming support, arrived in Chrome 111 and Edge 111.
Because this is a newer web platform API, support across browsers is not yet universal. You can detect availability at runtime by checking for a shadowRootMode property on HTMLTemplateElement's prototype:
function supportsDeclarativeShadowDOM() {
return HTMLTemplateElement.prototype.hasOwnProperty('shadowRootMode');
}
A lightweight polyfill approach
Putting together a working polyfill for Declarative Shadow DOM is surprisingly manageable. A polyfill does not need to reproduce the exact parsing or timing behavior of a native browser implementation—it only has to produce the same end result. The basic strategy is to scan the document for <template shadowrootmode> elements and attach each one as a shadow root on its parent element. This can be done once the DOM is ready, or you can hook the process into Custom Element lifecycle callbacks for more targeted timing.
(function attachShadowRoots(root) {
if (supportsDeclarativeShadowDOM()) {
// Declarative Shadow DOM is supported, no need to polyfill.
return;
}
root.querySelectorAll("template[shadowrootmode]").forEach(template => {
const mode = template.getAttribute("shadowrootmode");
const shadowRoot = template.parentNode.attachShadow({ mode });
shadowRoot.appendChild(template.content);
template.remove();
attachShadowRoots(shadowRoot);
});
})(document);



