Skip to content
Back to the Lab

Server Rendering Shadow DOM Without JavaScript

A deep technical guide to Declarative Shadow DOM, covering the template shadowrootmode attribute, open versus closed roots, delegating focus, cloneable and serializable shadow roots, what the HTML parser actually does, a fallback for unsupported browsers, and what the feature does not solve.

A dark Egnworks banner representing a shadow root attaching itself as the page parses.

A Web Component’s shadow tree has always been something JavaScript had to build. attachShadow() runs after a script loads and executes, which means a server rendered custom element arrives in the browser as a bare host tag with nothing inside it, encapsulated content and all, until the client side script that was supposed to attach it finally runs. Declarative Shadow DOM lets that same shadow tree exist directly in the HTML the server sends, attached by the parser itself before any script has a chance to run at all.

Why Shadow DOM Was Always a JavaScript Only Feature#

attachShadow() is a method on an element, called from a script, which means a shadow root cannot exist until that script has been downloaded, parsed, and executed. For a server rendered custom element, this created an unavoidable gap between the HTML arriving and the shadow content actually appearing, either a visibly empty host element or a flash of unstyled, unencapsulated content depending on how the component degraded in the meantime. There was no way to describe a shadow root in HTML itself, the entire feature assumed a script would always be there to build it.

Declaring a Shadow Root With template shadowrootmode#

A <template> element carrying a shadowrootmode attribute is treated specially by the HTML parser. Rather than staying an inert template, it is consumed and converted directly into a shadow root attached to its parent element.

<user-avatar>
  <template shadowrootmode="open">
    <style>
      :host { display: inline-block; border-radius: 50%; overflow: hidden; }
    </style>
    <img src="/avatars/default.png" alt="" />
  </template>
</user-avatar>

Once parsed, the <template> itself is gone from the DOM entirely, replaced by an actual #shadow-root attached to <user-avatar>, populated with the template’s contents, exactly as if attachShadow() and an append had already run, except none of that JavaScript ever executed.

Open Versus Closed#

shadowrootmode takes the same two values attachShadow() always accepted. open exposes the resulting shadow root through the host element’s shadowRoot property, so a script can still reach into it later if it needs to. closed hides that property, returning null, the same encapsulation boundary a closed shadow root created imperatively always enforced. Only the first template shadowrootmode inside a given host is converted this way, a second one is left as an ordinary, inert template element rather than attempting to attach a second shadow root.

Delegating Focus Into the Shadow Tree#

shadowrootdelegatesfocus, present with no value needed, sets the resulting shadow root’s delegatesFocus property to true.

<search-box>
  <template shadowrootmode="open" shadowrootdelegatesfocus>
    <input type="search" />
  </template>
</search-box>

With this set, focusing the host element itself, for example by clicking on padding around the input rather than the input directly, moves focus to the first focusable element inside the shadow tree instead of leaving it stranded on a host that cannot itself be typed into.

Making a Shadow Root Cloneable and Serializable#

Two more attributes control operations that do not normally reach into a shadow tree. shadowrootclonable sets the shadow root’s clonable property, so calling cloneNode() on the host actually copies the shadow root along with it rather than leaving the clone empty. shadowrootserializable sets serializable, which is what allows getHTML() to include the shadow tree’s contents when serializing the element back to a string, for example when a server needs to snapshot a page’s current state, shadow content included, after some client side interaction has changed it.

<my-widget>
  <template shadowrootmode="open" shadowrootserializable>
    <p>Widget content</p>
  </template>
</my-widget>

Neither of these is implied by shadowrootmode alone, a shadow root created this way is not cloneable or serializable by default, matching how attachShadow() never made a shadow root cloneable or serializable by default either.

What the Parser Actually Does#

This entire mechanism lives in HTML parsing, not in JavaScript execution. The browser recognizes template shadowrootmode as a special construct while it is still building the DOM from the raw HTML bytes, the same phase where it already handles things like implicitly closing an unclosed <p> tag. This is what makes the feature meaningfully different from a script running attachShadow() as early as possible, the shadow tree exists before the parser has even reached the closing tag of the host element, well before any script, inline or external, gets a chance to run.

A Complete Server Rendered Component Example#

A server can emit a fully functional, styled custom element without any client side JavaScript needing to run before it looks correct.

<rating-stars rating="4">
  <template shadowrootmode="open">
    <style>
      :host { display: inline-flex; gap: 2px; }
      .star { color: gold; }
    </style>
    <span class="star">&#9733;</span>
    <span class="star">&#9733;</span>
    <span class="star">&#9733;</span>
    <span class="star">&#9733;</span>
    <span>&#9734;</span>
  </template>
</rating-stars>

<script>
  customElements.define("rating-stars", class extends HTMLElement {
    connectedCallback() {
      // shadowRoot already exists, populated by the parser
      this.shadowRoot.addEventListener("click", () => this.rate());
    }
    rate() {
      // interactive behavior wires up here, once the script does load
    }
  });
</script>

The star rating renders correctly, styled and encapsulated, the moment the HTML arrives, and the class definition that follows only needs to add interactive behavior on top of a shadow tree that already exists, rather than building that tree from scratch.

A Fallback for Browsers Without Support#

A browser without support simply leaves the <template> as an inert template element, rendering nothing, since a template’s content is never displayed on its own. A small inline script placed early in the document can detect this and attach the shadow root manually as a fallback.

<script>
  (function attachShadowRoots(root) {
    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);
</script>

This walks the document for any template shadowrootmode the browser did not convert on its own, and performs the equivalent attachShadow() call by hand, recursing into the newly created shadow root in case a component nests another declarative shadow root inside its own template.

What This Does Not Solve#

Declarative Shadow DOM covers the moment a shadow tree first exists, it does not give a component any built-in reactivity, data binding, or a way to update its shadow content later without JavaScript. A rating component still needs script to change its rating after the initial render, the feature only guarantees that the first render does not have to wait for that script to arrive.

Browser Support#

Declarative Shadow DOM is supported in Chrome, Edge, Firefox, and Safari, and counts as broadly available today, making the manual fallback script above a safety net for older browser versions rather than a requirement for a project targeting current ones.

Conclusion#

Declarative Shadow DOM moves the one part of a Web Component that always required JavaScript, actually attaching its shadow tree, into the HTML parser itself. shadowrootmode creates the root, shadowrootdelegatesfocus fixes a focus gap the shadow boundary otherwise creates, and shadowrootclonable and shadowrootserializable extend cloning and serialization into a tree that used to be invisible to both. What it does not do is give a component behavior beyond that first render, which is still exactly what a component’s own script is for, just no longer something a visitor has to wait on before the component looks right.

References#

MDN: template shadowrootmode Attribute

MDN: Using Shadow DOM

web.dev: Declarative Shadow DOM

Can I Use: Declarative Shadow DOM