Step 09

Declarative shadow DOM

Eight steps of components, and all of them share a dependency nobody has said out loud: none of it exists until JavaScript runs. The parser meets <user-card>, finds no definition for the name, and renders an empty inline element; everything that makes it a card is created afterwards, by a script, inside a callback. Declarative shadow DOM removes that dependency for the markup half. A <template> with one attribute is consumed by the parser and becomes the host's shadow root — styled, slotted, and painted before a byte of your JavaScript has been fetched. This step covers that attribute, the one-line guard that makes the later upgrade an adoption rather than a rebuild, the API that refuses to parse a declarative shadow root and the one that will, and what all of it means for a page built the way this one is.

Learning Objectives

  • Render a shadow root from markup, with no JavaScript at all
  • Adopt a parser-delivered root instead of rebuilding it
  • Know which APIs parse a declarative shadow root — and which deliberately do not
  • Serialize a shadow root back out to HTML
  • Say what all of it does and does not solve for a statically generated site

The component that is not there yet

Follow what the browser actually does with <user-card> on a first load. The parser reaches the tag, sees a name it does not recognize, and creates an element for it — an HTMLElement with no behavior, no shadow root, and, unless the page styled it, display: inline. It has children, because you wrote them, but nothing arranges them. Then the page paints. What the user sees at that moment is whatever your light DOM happened to be: a stack of unstyled spans, or, in the common case where the component supplies all of its own markup, nothing at all.

Some time later the module arrives, define() runs, every matching element upgrades, and connectedCallback builds the shadow tree. The card appears. The gap between those two moments is the flash: a blank or misshapen region that reflows into a component once the network and the main thread have both cooperated. On a fast connection it is a flicker. On a slow one it is the page. And if the script fails to parse, or 404s, or is blocked by an extension, the gap is permanent — there is no state in which the component renders and no error the user can act on.

That is a strange place for this series to have ended up, because step 01 started from the opposite premise. Markup first, behavior after: a <product-card> that meant something before any script existed, and worked with HTML and CSS alone. Every step since has added capability, and each one quietly moved more of the component's substance out of the document and into a callback. Declarative shadow DOM is how you get the first half back without giving up the second.

<template shadowrootmode>

The whole feature is one attribute. A <template> that carries shadowrootmode and sits as a direct child of some element is not treated as a template at all: the HTML parser consumes it, attaches a shadow root to that parent, moves the template's contents into the root, and removes the template from the tree. By the time the document is parsed there is no <template> left to find — there is a host with a shadow root, exactly as if attachShadow() had been called and filled.

<user-card> <template shadowrootmode="open"> <style>:host { display: block; }</style> <slot name="name">Anonymous</slot> </template> <span slot="name">Ada Lovelace</span> </user-card>

Read that markup with the previous eight steps in mind and notice how much of the model is already present with no JavaScript anywhere. The <style> is scoped to the root, so :host works and the rules cannot leak. The <slot> projects the light-DOM <span> into place, fallback content and all, by exactly the mechanism step 06 described. What is missing is only the behavior: there is no class, no registered name, nothing listening. The boundary and the markup arrived without them.

Four attributes in all: the one that triggers the behavior, and three that set the options attachShadow() would otherwise take. They have to be spelled on the template because there is no call to pass them to.

  • shadowrootmode"open" or "closed"; required, and the reason the parser treats the template specially at all
  • shadowrootdelegatesfocus — the delegatesFocus: true that step 08 needed to route a label click inward
  • shadowrootclonable — the root comes along when the host is cloned with cloneNode(true)
  • shadowrootserializable — the root may be read back out as markup; see the serialization section below

A closed declarative root is worth a moment's thought, because the markup is right there in the document for anyone to read — which is the clearest demonstration yet of step 04's point that mode: "closed" is an encapsulation choice and not a security one. It also breaks the guard in the next section, and not gently: this.shadowRoot is null for a closed root, so the check concludes there is nothing there and calls attachShadow({ mode: 'open' }) — against a root whose mode is closed. That mismatch throws NotSupportedError: The requested mode does not match the existing declarative shadow root's mode, uncaught, taking the rest of connectedCallback down with it. The element ends up with the parser's markup and none of its behavior. The element can still find its own root through attachInternals().shadowRoot — the same ElementInternals object step 08 used, which reports the root whatever its mode — but that is one more thing to remember for a mode you probably did not need.

Hydration is adoption, not reconstruction

Eventually the definition loads and the element upgrades. The constructor runs, then connectedCallback — and it runs against an element that already has a shadow root, already populated, already painted. Every component in this series so far has begun that callback by creating one.

Do that here and you have written the bug this whole step exists to avoid — and nothing will stop you. attachShadow() normally throws a NotSupportedError on an element that already has a root, but a root the parser delivered is a deliberate exception: the call succeeds, empties that root, and hands it back. The exception exists so that components written before any of this shipped do not break on a server-rendered page. What it costs you is the warning. The markup the server sent is discarded, rebuilt from a string, and the flash you paid bytes to avoid comes back — with nothing in the console to say so. The guard is one line.

Two conditions on that exception are worth knowing before you rely on it. The mode has to match: asking for { mode: 'open' } against a template that declared shadowrootmode="closed" throws NotSupportedError rather than adopting, which is the closed-root trap from the previous section. And it is one-shot — the first call converts the parser's root into an ordinary shadow root, so a second attachShadow() on the same element throws NotSupportedError: Shadow root cannot be created on a host which already hosts a shadow tree, exactly as it would anywhere else. The exception is a migration ramp for old code, not a mode you can build on.

connectedCallback() { if (!this.shadowRoot) { this.attachShadow({ mode: 'open' }).innerHTML = TEMPLATE; // runtime path only } this.#wire(); // listeners only — the markup already exists }

That shape is worth reading as a claim about responsibility rather than as a null check. The markup is no longer the callback's job in the common case; the callback's job is behavior. It queries nodes that are already there, attaches listeners to them, and sets whatever state the static markup could not express. Everything above the guard is a fallback for the one case the server did not cover.

Which means the callback has to be written so that both entries land in the same place. A component whose declarative markup and whose runtime template have drifted apart renders one way on first load and another way when created with createElement(), and the difference shows up in whichever of the two paths you test less.

One callback, two arrivals

The same connectedCallback must handle a root the parser delivered and a root nobody has built yet, and the only thing that tells them apart is this.shadowRoot. There is no "was I server-rendered" flag and no separate hydration hook — the platform gives you one entry point and a truthiness check, and that check is the entire hydration protocol.

innerHTML will not parse it

Here is the one that costs an afternoon. You fetch a fragment of server-rendered HTML, complete with <template shadowrootmode>, assign it to innerHTML, and get no shadow root. No exception, no warning, no console message. The template is simply still there — an ordinary, inert <template> element with your component's markup sitting unrendered inside its .content, precisely as step 06 described templates behaving. The card does not appear, and the markup you are staring at in DevTools looks correct.

const MARKUP = '<user-card><template shadowrootmode="open">...</template></user-card>'; // Does NOT create a shadow root — the template stays inert, at any depth host.innerHTML = MARKUP; // Does create it — and the name is telling you to trust the source host.setHTMLUnsafe(MARKUP); host.querySelector('user-card').shadowRoot; // ShadowRoot // But a template at the top level of the string has no parent inside the // fragment, so nothing adopts it — including the element you called on host.setHTMLUnsafe('<template shadowrootmode="open">...</template>'); host.shadowRoot; // null — still an inert <template> in host's children

Read that last pair carefully, because it is the same silent nothing arriving by a second route. The parser attaches each root to the template's parent within the fragment it is building, and the element you called the method on is not part of that fragment — it is the context, not a node in the result. So setHTMLUnsafe() cannot give the context element a shadow root, however the string is written: a <template shadowrootmode> at the top level of the string has nothing to attach to and survives as an ordinary inert template. Wrap it in the host element it belongs to and the identical string works. If you need a root on the element itself, that is what attachShadow() is for.

This is deliberate, and the reason is worth understanding because it explains the second method's name. There is an enormous amount of existing code that assigns strings to innerHTML, much of it after passing the string through a sanitizer that walks the resulting tree. If innerHTML had been taught to create shadow roots, every one of those call sites would have gained the ability to attach content that a sanitizer written before this feature existed does not look inside — markup that renders to the user while being invisible to the code that was supposed to inspect it. Silently changing what an old API does to old input was not an option, so the capability went into a new method, and that method was named after the promise you are making by calling it.

Element.setHTMLUnsafe() is the in-place form; Document.parseHTMLUnsafe() is the standalone one, returning a whole document. Both parse declarative shadow roots. Neither sanitizes anything. The rule is the ordinary one for injecting markup, only with sharper consequences: it is safe for HTML you generated, and it is never safe for HTML a user wrote.

Which is not the end of the story, because each has a sanitizing sibling and they are the ones to reach for when the HTML is not yours. Element.setHTML() and Document.parseHTML() parse declarative shadow roots and run the Sanitizer over the result, including inside the roots they just created: a string whose template holds <p onclick="x()">hi</p><script>…</script> comes back as a shadow root containing <p>hi</p>, handler and script both gone, where setHTMLUnsafe() would have kept them. No options are needed for that: a declarative template is consumed by the parser into a shadow root before the sanitizer walks the tree, so the allowed-element list never gets a say over it.

That list does have a say over everything else, and it is the real limit on using the safe pair with components. The built-in configuration allows a fixed set of known HTML elements, and your custom element is not among them — so the host is removed and its shadow root goes with it.

// The declarative root survives sanitizing — the host does not host.setHTML('<user-card><template shadowrootmode="open">…</template></user-card>'); host.querySelector('user-card'); // null — dropped as an unknown element // A plain <template>, with no shadowrootmode, is dropped too host.setHTML('<div><template><p>tpl</p></template></div>'); host.innerHTML; // "<div></div>"

So the sanitizing pair is the right reach for untrusted markup, and it handles declarative shadow roots correctly — but shipping components through it means widening the configuration to admit your own element names, deliberately and by name. That is a decision to make with your eyes open, not a default to lean on.

⚠️ The failure is silent

Nothing throws. The component just does not render, and the template that should have been consumed is still in the DOM — which is the tell. If you are debugging a server-rendered component that works on a full page load and not when the same markup arrives over fetch(), look for a leftover <template shadowrootmode> in the inspector before you look anywhere else — and if you are already calling setHTMLUnsafe(), check whether that template is the outermost node of the string you handed it. Both mistakes leave the same wreckage.

Serializing back out

The markup in this step has to come from somewhere. Writing it by hand is fine for one card and hopeless for a page of them, so the platform provides the other direction: a way to read a live element back out including its shadow roots, in the declarative form the parser understands.

const html = host.getHTML({ serializableShadowRoots: true });

getHTML() with no arguments is the innerHTML getter with a longer name: it walks the light DOM and skips shadow roots entirely, which is the symmetric counterpart of the setter refusing to create them. Pass serializableShadowRoots: true and every shadow root marked serializable is emitted as a <template shadowrootmode> in the output, nested exactly where the parser will want to find it. There is also a shadowRoots option taking an explicit array of roots to include, which is how a closed root — whose reference only its own component holds — can be serialized by that component.

Serializability is opt-in for the same family of reasons the parsing is. A root is included only if it was created with attachShadow({ mode: 'open', serializable: true }), or arrived from a template carrying shadowrootserializable. Forget the flag and getHTML() returns markup with the shadow roots quietly missing — which will look, on the round trip, exactly like the innerHTML bug above, from the other end.

What this closes is the loop. A rendering environment — a server, a build step, a headless browser — can define the components, instantiate them, let them build their shadow roots the ordinary way, and then serialize the whole result to HTML that any browser will render without running any of it again.

Demo: a shadow root that arrives in the markup

Two <user-card> elements and one class. The first card is in the page's source with its shadow root written out as a template, so the parser builds it — border, type scale, grey role line and all — before the module at the bottom of the file has been requested. The status line inside it reads "Not yet upgraded" because that string is in the markup; by the time you see the frame the definition has loaded and overwritten it, which is what the upgrade did. Reload with JavaScript disabled and the card renders identically, still saying it has not been upgraded.

The greet button proves the second half. It is in the shadow root the parser built, and nothing in the page attached anything to it until connectedCallback ran and adopted that root — markup from the parser, listener from the class, one component.

Then press "Add a card at runtime" and watch the difference. The same class handles the new element, but there is no declarative template for it, so the guard falls through to attachShadow() and the fallback string — which contains markup and no <style>. Style is scoped per root, so the runtime card gets none of the first card's appearance, and no rule in the page can supply it either. That is not a flaw in the demo; it is the same boundary from step 04, seen from the angle where forgetting one thing costs you every rule at once.

What this means for a static site

This page was generated at build time. Its HTML was written to disk before you asked for it, and the server did nothing but hand over a file. That is a good arrangement, and it has one well-known hole: anything on the page made of components is not in that file. The generator emits <my-card> and the browser has to be told separately what one is.

Declarative shadow DOM is what closes the hole without abandoning the arrangement. A generator that can render your components at build time and serialize the result — the getHTML() round trip from two sections ago — writes files whose components are already components: styled, composed, and painted on first load, with the JavaScript arriving afterwards to add the behavior. That is not a new idea. It is precisely the argument step 01 made about semantic markup, applied one level up: ship the thing itself, enhance it after.

Be honest about what is not solved. The build has to produce the markup the client would have produced, and "would have produced" is doing a great deal of work in that sentence — a component that reads the viewport, or the time, or localStorage renders differently in a build environment than in a browser, and the mismatch surfaces as a component that visibly changes shape a moment after load, or as a listener attached to a node that is not the node the user sees. Rendering the components at build time also means running them somewhere with a DOM, which is a constraint on how the components may be written.

And there is no standard, framework-free tooling for any of it yet. The framework-attached implementations are real and shipping; the generic pipeline — define, render, serialize, guard on this.shadowRoot — is something you presently assemble yourself from the pieces this step describes. Every piece exists. The convention does not.

The bytes are not free

A declarative shadow root is emitted once per instance, styles included. Fifty cards on a page means fifty copies of the same markup and the same <style> in the HTML — which compresses extremely well and is still not nothing. The trade is real bytes now against a blocked first paint later, and it is worth making deliberately rather than everywhere.

Where the series ends

Nine steps, and the shape they traced is the one the series index claimed at the start: a component is a name, some markup, some behavior, and a boundary, and the platform ships all four as separate things.

Steps 01 to 03 were the name — a hyphenated tag as semantics with no script at all, then customElements.define(), upgrade, and the attribute-versus-property surface that makes an element addressable from HTML. Step 04 and step 07 were the boundary: what it costs, and how to design the few deliberate holes anyone else will ever be able to style through. Step 06 and this one were the markup — inert templates and slots, then markup that arrives already rendered. Step 05 and step 08 were the behavior: the lifecycle, the listener that outlives its element, and what it takes for the browser to treat your element as one of its own form controls.

The through-line is that those four are separable, and that knowing which of them you actually need is most of the skill. Nearly every component in this series could have been built with fewer of the four than it used. A semantic tag and a stylesheet is a legitimate component. A custom element with no shadow root is a legitimate component, and frequently the better one — it participates in forms, inherits the page's CSS, and is findable by everything that walks the document. Reaching for all four every time is the habit a framework trains, because a framework hands you all four as one object. The platform does not, and that is the advantage, not the friction: you pay for each piece separately, so you can decline the ones you do not need.

For the layer underneath — how the parser builds the tree these components attach to, what the event loop is doing while a module downloads, and why the flash in this step's first section happens where it does — see The Browser for Programmers. For the sibling topics, and the other platform capabilities that pair with this one, see the APIs series.