Web Platform · HTML and Semantics
Template Contents, Slots, and Declarative Shadow DOM
A template owns a document fragment that never enters the page, slots decide which light-DOM children render where, and a shadowrootmode attribute can attach a shadow root during parsing.
document.querySelector('template').children.length is zero for a template containing a full card layout. The markup is there, but it is not in the template element’s subtree: the template owns a separate DocumentFragment exposed as template.content, and the template element itself has no child nodes at all. Inserting a template into the document inserts an empty element.
This split between an element and its contents is not a quirk of the DOM binding. It is the mechanism that makes templates useful: the fragment is parsed, keeps its structure, and stays inert until it is cloned into a live document. The parsing rules for the contents, the slot assignment rules for shadow trees, and the declarative form of shadow roots that can be attached by the parser are three stages of the same model.
A fragment owned by an element, not contained in it
The template element has a template contents property, a DocumentFragment created along with the element. When the parser meets <template>, it switches to the in template insertion mode and appends everything until </template> to that fragment rather than to the template. The fragment’s children are ordinary nodes in every respect except their position: they have the same node types, the same attributes, and the same parsing rules, including nested templates with their own fragments.
The owner document of that fragment is a separate inert document, not the page’s document. A node belongs to one document, and inserting it elsewhere adopts it, which moves it out of the template rather than copying it. Appending template.content.firstElementChild to the page therefore empties part of the template; importNode or cloneNode is the way to get a copy that leaves the original intact.
Inertness has three practical effects. Scripts inside template content do not execute, because the parser does not run scripts in a document without a browsing context. Resources referenced by img, video, or iframe do not load, since loading is driven by a document that is being rendered. Custom elements inside template content are not upgraded while they sit there; they are created as unknown elements and upgrade when they reach the main document.
template.content is a live fragment, and modifying it modifies the template. Appending a node to template.content changes what subsequent clones contain. This is occasionally useful for assembling a template from parts and occasionally a source of shared-state bugs when a component caches a clone and then mutates the clone’s nodes while assuming they were copies.
Getting content out
template.content.cloneNode(true) produces a deep copy of the fragment, including element nodes, attributes, and text. document.importNode(template.content, true) does the same and additionally adopts the nodes into the target document, which is the correct call when the template and the destination have different owner documents. Both produce nodes that are ready to insert.
The standard pattern is to clone once per instance and then fill in the clone’s parts:
const template = document.querySelector('#card');
const fragment = template.content.cloneNode(true);
fragment.querySelector('.title').textContent = item.title;
fragment.querySelector('.meta').textContent = item.updated;
list.append(fragment);
querySelector works on the fragment because a DocumentFragment implements the same parent-node interface as an element, which also means IDs inside template content are not registered in the document’s ID map until the content is inserted. Two clones of the same template inserted into the page produce duplicate IDs, and getElementById returns the first. Templates intended for repeated use should avoid id attributes and rely on classes or on a scoping mechanism.
Nested templates are independent. Cloning the outer template clones the inner template element as an empty element with its own content fragment copied, not merged. A component that expects to find the inner template’s markup as regular descendants of the outer clone will find nothing until it also clones the inner content.
Slots and the flattened tree
A shadow root introduced with attachShadow creates a second tree attached to an element, the host. The host’s ordinary children remain its children in the light tree; they are not moved, and they are not visible to selectors inside the shadow tree. Rendering follows a flattened tree: for each slot element in the shadow tree, the browser assigns the host’s children that match the slot’s name, and renders them in the slot’s place.
A slot with no name is the default slot and receives every child of the host that has no slot attribute. A slot with name="footer" receives the host’s children whose slot attribute equals footer. Assignment is by attribute equality on the direct children of the host, not by selector, and not recursively: a grandchild with slot="footer" is not assigned anywhere, because its parent is not the host.
Fallback content is the slot’s own children, used when no node is assigned to it. slot.assignedNodes() returns the assigned nodes for the slot and assignedElements() filters to elements; both accept an option to flatten, which resolves nested slots in a host that is itself inside another shadow tree. The slotchange event fires on the slot when the assigned node list changes, and it does not fire for changes inside an assigned node.
Styling follows the same boundary. :host matches the host element from inside the shadow tree, :host(.active) adds a condition on the host, and ::slotted(selector) matches assigned nodes at the top level only. A rule inside the shadow tree cannot reach into the light tree beyond the slotted elements, and a rule outside cannot reach into the shadow tree, except for inheritable properties that flow through the host. CSS custom properties inherit through the boundary, which is the intended channel for theming.
Delegates focus is a separate concern: a shadow root created with delegatesFocus: true forwards focus to the first focusable descendant when the host is focused, and clicking anywhere in the host focuses the shadow tree. That behaviour is set at creation and cannot be changed later.
Declarative shadow DOM
The imperative path requires script: the element must exist, then attachShadow must run, then the shadow content must be built. Declarative shadow DOM moves that into the parser. A template element with a shadowrootmode attribute of open or closed, appearing as a child of an element that can host a shadow root, causes the parser to create the shadow root and move the template’s contents into it. The template element itself does not remain in the tree; its contents become the shadow root’s children.
<user-card>
<template shadowrootmode="open">
<style>
:host { display: block; border: 1px solid; }
::slotted(h2) { margin: 0; }
</style>
<h2><slot name="title">Untitled</slot></h2>
<slot></slot>
</template>
<h2 slot="title">Deployment notes</h2>
<p>Rolled out to two regions.</p>
</user-card>
After parsing, user-card has a shadow root with the style and the two slots, and its light children are assigned to them. No script ran. When the custom element definition later registers, the constructor runs on an element that already has a shadow root, and this.shadowRoot returns it; calling attachShadow again throws, so the upgrade path has to check first.
Four attributes modify the behaviour. shadowrootmode selects open or closed. shadowrootdelegatesfocus sets the focus delegation flag. shadowrootclonable allows the shadow root to be cloned when the host is cloned. shadowrootserializable marks the root as eligible for serialisation by getHTML. The mode attribute was originally spelled shadowroot and was renamed to shadowrootmode before the feature stabilised, so markup written against an early draft may not work.
The parser applies declarative shadow DOM only during parsing of an HTML document. innerHTML does not create shadow roots, even when the markup contains a shadowrootmode template; the template is inserted as an inert template and nothing else happens. The methods that do honour it are the unsafe parsing methods, Element.setHTMLUnsafe(), ShadowRoot.setHTMLUnsafe(), and Document.parseHTMLUnsafe(), which exist precisely because they drop the sanitisation that the safe versions perform.
Only the first qualifying template attaches a shadow root. A second one inside the same host is left as an ordinary template element. The host must also be an element type that can host a shadow root; a div can, an input cannot.
Serialising the shadow tree back out
element.getHTML() serialises the element’s subtree, with options to include shadow roots. A root participates when it was created with serializable: true and the serializableShadowRoots option is set, or when it is passed explicitly in the shadowRoots array. Closed roots are never included automatically. The output places each serialised shadow root as a template shadowrootmode="..." child of its host, which is the same form the parser accepts, so a serialised tree can be re-parsed into an equivalent structure.
The round trip is the point. Server rendering with a shadow-based component library can emit the shadow root in markup, the client parser attaches it without waiting for script, and the custom element definition can treat the existing root as its own. The saving is the flash between first paint and the first script execution, and the cost is that the markup must be emitted by code that understands the shadow model rather than by a generic HTML serialiser, which would emit the template as an inert element with no shadow root.
Where the boundary bites
Template content and shadow content fail in similar ways. Queries from the document do not reach either: document.querySelector('.title') finds nothing inside a template or a shadow root. Event handlers attached at the document see events from shadow content only when the event is composed, and the target is retargeted to the host. Form participation, label association, and aria-labelledby do not cross the boundary, so a labelled control inside a shadow root needs its label inside the same root or an explicit ElementInternals-based association.
The semantics that do cross are the ones the browser implements on the flattened tree: focus navigation, slot assignment, and the accessibility tree, which merges the shadow content into the host’s node. That merge is why a component built with slots can present a single accessible name computed from light and shadow content together, under the rules in Accessible Name Computation: Which Attribute Wins.
References
- HTML Standard — The template element — WHATWG
- DOM Standard — Shadow trees — WHATWG
- MDN — Element.attachShadow() — MDN Web Docs