Declarative Shadow DOM lets the HTML parser build a shadow root straight from markup, with no JavaScript involved. Put a <template shadowrootmode="open"> inside a host element and the parser attaches it as that element’s shadow root, styles and all, before anything hydrates. That is the piece server-side rendering of web components was missing.
It has been in every engine for a while. Chrome and Edge shipped it in 111, Safari in 16.4, Firefox in 123, and the web-features data marks it Baseline Widely available as of August 2026. So the question worth answering is what changes in your component code once you start emitting it.
What does shadowrootmode do at parse time?
The parser sees the opening <template> tag, reads shadowrootmode, and attaches a ShadowRoot to the parent element there and then. The template element itself never lands in the DOM. By the time the document has finished parsing, the host already has a populated shadow tree.
<user-card>
<template shadowrootmode="open">
<style>
:host { display: block; border: 1px solid #d4d4d8; padding: 1rem; }
::slotted(h2) { margin: 0 0 0.25rem; font-size: 1rem; }
</style>
<slot name="name"></slot>
<slot name="role"></slot>
</template>
<h2 slot="name">Ada Lovelace</h2>
<p slot="role">Engineer</p>
</user-card>
Two values are allowed, open and closed, matching the mode option of attachShadow(). Four sibling attributes set the other properties you would otherwise pass to that call: shadowrootclonable, shadowrootdelegatesfocus, shadowrootserializable and shadowrootslotassignment. The first three are boolean and default to false. The last takes named or manual, and defaults to named.
One rule catches people out. MDN’s template reference spells it out: only the first <template> in a given parent with a valid shadowrootmode becomes a ShadowRoot. A second one is parsed as an ordinary HTMLTemplateElement, and it cannot be promoted afterwards. If your templating layer can emit the same partial twice into one host, what you get is a shadow root plus a stray template, not two roots.
Do you need declarative shadow DOM for SSR?
Only if your components use shadow DOM at all. Plenty of custom elements render into the light DOM, style themselves with ordinary global CSS, and have always server-rendered fine.
The gap this closes is narrow and specific. With attachShadow() called from a constructor, the host is empty of shadow content until the class definition downloads, parses, and the element upgrades. Slotted children are in the document the whole time, so they paint, unstyled and in source order, and then get rearranged when the root finally appears. On a slow connection that reflow is visible. Declarative Shadow DOM removes it, because the root arrives in the same byte stream as the content it wraps.
Lit ships a server renderer for exactly this, @lit-labs/ssr, which renders components to HTML including their shadow roots. It is still a Lit Labs package, so treat the API as unstable. If you are calling web components from a React tree instead, the interop rules changed in React 19, and our notes on web components in React cover what now works without a wrapper.
How do you hydrate over a root the parser already made?
Your custom element definition has to cope with a shadow root that already exists. The clean way to reach it is ElementInternals, which works for closed roots too.
class UserCard extends HTMLElement {
#internals = this.attachInternals();
connectedCallback() {
let shadow = this.#internals.shadowRoot;
if (!shadow) {
// No declarative root: client-rendered path.
shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = TEMPLATE;
}
shadow.querySelector('button')?.addEventListener('click', this.#expand);
}
#expand = () => this.toggleAttribute('open');
}
customElements.define('user-card', UserCard);
Calling attachShadow() on an element that already has a declarative root no longer throws, which is what makes that fallback branch safe to leave in. The existing root is emptied and returned, so a component written before declarative shadow DOM existed still behaves correctly against markup that now carries one. The catch is the mode. Ask for open on a host whose declarative root was closed and you get a NotSupportedError.
Why does innerHTML ignore shadowrootmode?
Because declarative shadow roots are attached by the HTML parser, and innerHTML does not go through that path. Assign a string containing <template shadowrootmode="open"> to innerHTML and you get an inert template element sitting in the DOM. DOMParser does the same. This bites the first time you fetch a fragment and inject it.
const res = await fetch('/fragments/user-card/42');
const html = await res.text();
// Wrong: the template stays a template.
// target.innerHTML = html;
document.querySelector('#target').setHTMLUnsafe(html);
setHTMLUnsafe() exists on both Element and ShadowRoot, and Document.parseHTMLUnsafe() does the same job for a whole document. The word in the middle of the name is the warning: called with no options it performs no sanitisation whatsoever. It takes a TrustedHTML or a string, plus an optional { sanitizer }, where the value "default" applies the built-in XSS-safe configuration. If the markup came from anywhere you do not control, pass a sanitiser or use setHTML(), and enforce Trusted Types through the same header you are already tuning for nonces and hashes in your CSP.
The first-root-only rule applies here too. If the input declares several shadow roots on one host, only the first becomes a root.
Serialising a shadow root back to HTML
The reverse trip needs opting in at both ends. Mark the root serialisable in the markup, then ask for it at the call site.
// <template shadowrootmode="open" shadowrootserializable>
host.getHTML({ serializableShadowRoots: true });
// Or name the roots explicitly, serializable or not, open or closed:
host.getHTML({ shadowRoots: [host.shadowRoot] });
Plain getHTML() with no arguments behaves like innerHTML and skips shadow content entirely, which is the default you want most of the time. The API has been Baseline since September 2024.
What it costs once you have a hundred of them
Constructable stylesheets do not work here. A declarative root takes its CSS from an inline <style> or a <link>, both of which are supported, but adoptedStyleSheets is a JavaScript property with no markup equivalent. Render two hundred cards and you ship two hundred copies of the same rule set. A <link rel="stylesheet"> repeats the tag rather than the download, so past a few declarations that is the cheaper shape, at the cost of a request the inline version did not need.
Closed mode costs more than it saves. You lose element.shadowRoot for debugging and for tests, and anything that needs access has to carry the ElementInternals reference from the constructor onwards.
What I would actually do: reach for declarative shadow DOM when you are already committed to shadow DOM and first paint matters, which in practice means content-heavy pages where the components carry visible text. For a design system used inside an app shell that sits behind a spinner anyway, the extra bytes per instance buy nothing. And if you are choosing now, with no existing shadow roots to honour, light-DOM custom elements plus @scope solve the encapsulation problem without any of this.
We spend a fair bit of time on pages that paint late because most of the markup only exists after hydration, and picking the rendering strategy per component is usually where the fix starts. If that describes your build, that is what our site speed work covers.