Lesson 16 / 24
Templates and Slots
The markup container that stays unrendered and the rules for copying it; the slot assignment algorithm, fallback content, the flattened tree, and which tree placed content still belongs to.
Contents
The previous lesson showed how the shadow tree is isolated, not where its content comes from. Two open questions remained. First, if the component’s internal structure is built node by node for every instance, fifteen badges run the same setup code fifteen times. Second, the host’s light tree written in the document is nowhere visible: the label and unit written inside the badge disappear.
This lesson builds two mechanisms together: a markup container that stays unrendered, and the points in the shadow tree where content from the light tree gets placed.
An Unrendered Markup Container
There is an element in HTML whose content is parsed but does not enter the document: the template. The parser turns markup inside the template into nodes, but places them not in the document tree but in a separate document fragment belonging to the template.
The result is that the content is inert. An image inside it does not download, a script inside it does not run, style rules inside it are not applied, its elements take no part in layout computation. The markup sits written on the page but takes part in none of the page’s behavior.
This is the right container for a component’s internal structure. The markup sits readably in the document; when needed, the component copies the container’s content into the shadow tree. The content is not reparsed from text every time — parsing happened once, everything after is tree copying.
Reaching a template’s content by looking at the element’s children gives no result; the content sits in a separate fragment and is read through it. This surprises on first encounter: a template element’s child node count is zero.
Cloning and What a Clone Does Not Carry
The copy has to be deep: an operation copying not just the root but the entire subtree. A shallow copy gives the template’s root, not its content.
What the copy does not carry is more instructive than what it does.
Listeners are not copied. Even if a listener is attached to a node in the template, the copy does not get it; a listener is attached after the copied tree has been placed. This is not a gap but a necessity: a listener is a function reference and is not part of markup.
Ids are copied. If an id is written in the template, the same id results in every copy. In the document tree, this breaks id uniqueness. In the shadow tree, no problem arises: as seen in the previous lesson, every shadow tree has its own id space. This is why using ids in templates is tied to the shadow tree.
Attributes are copied, user state is not. The distinction from the DOM API lesson applies here too: the copy gets the initial state the attributes declare; a value the user changed on the source element does not carry over to the copy.
Copying produces nodes in a fragment not bound to the document. The rule built in the DOM API lesson works here on its own: no layout computation is triggered while the copy is being prepared; the computation happens only once, when the fragment is added to the shadow tree.
Slots and the Assignment Rule
The template gives the component’s own structure. Content the component receives from outside comes from somewhere else: the host’s light tree. The shadow tree marks the points where this content gets placed with slot elements.
The rule comes down to three points. Only the host’s direct children can be assigned;
grandchildren are not. A child carrying a slot attribute goes to the same-named slot, one
without it goes to the unnamed default slot. A child matching no slot’s name is assigned
nowhere and is not rendered.
// slot-assignment.mjs — the slot assignment algorithm and the flattened tree const element = (name, attrs = {}, ...children) => ({ name, attrs, children, text: null }); const text = (value) => ({ name: "#text", attrs: {}, children: [], text: value }); const slot = (name, ...fallback) => element("slot", name ? { name } : {}, ...fallback); function* walk(root) { yield root; for (const c of root.children) yield* walk(c); } const label = (n) => n.text !== null ? `"${n.text}"` : n.name === "slot" ? `slot[${n.attrs.name ?? "default"}]` : n.name + (n.attrs.slot ? `[slot=${n.attrs.slot}]` : ""); // Assignment: only the host's DIRECT children are assigned; those without a slot attribute // go to the default slot, those with one go to the same-named slot. Order is the light tree's order. function assignments(host, shadowRoot) { const map = new Map(); for (const n of walk(shadowRoot)) if (n.name === "slot") map.set(n.attrs.name ?? "", []); for (const child of host.children) { const wanted = child.attrs.slot ?? ""; if (map.has(wanted)) map.get(wanted).push(child); } return map; } // Flattened tree: each slot is replaced by its assigned nodes, or by fallback content if unassigned. function flatten(node, map, depth = 0, lines = []) { if (node.name === "slot") { const assigned = map.get(node.attrs.name ?? "") ?? []; const visible = assigned.length ? assigned : node.children; const source = assigned.length ? "light" : "fallback"; for (const item of visible) { lines.push(`${" ".repeat(depth)}${label(item)} (${source})`); for (const c of item.children) flatten(c, map, depth + 1, lines); } return lines; } lines.push(`${" ".repeat(depth)}${label(node)}`); for (const c of node.children) flatten(c, map, depth + 1, lines); return lines; } // The host's children as written in the document (the light tree) const host = element("measurement-badge", { id: "temperature" }, element("span", { slot: "label" }, text("Temperature")), element("strong", {}, text("-4.2")), element("span", { slot: "unit" }, text("°C")), element("span", { slot: "source" }, text("station-3"))); // Shadow tree const shadowRoot = element("#shadow-root", {}, element("div", { class: "box" }, slot("label", text("Measurement")), element("output", {}, slot(null, text("—"))), slot("unit", text("unitless")))); const map = assignments(host, shadowRoot); console.log("assignments:"); for (const [name, nodes] of map) console.log(` slot[${name || "default"}] <-`, nodes.map(label).join(", ") || "(empty -> fallback content)"); const unassigned = host.children.filter((c) => !map.has(c.attrs.slot ?? "")); console.log("unassigned light nodes:", unassigned.map(label).join(", ") || "(none)"); console.log("\nflattened tree:"); console.log(flatten(shadowRoot, map).join("\n")); // A light tree that is an empty host, with the same shadow tree: every slot shows its fallback content. const emptyHost = element("measurement-badge", { id: "humidity" }); console.log("\nlight tree, empty host:"); console.log(flatten(shadowRoot, assignments(emptyHost, shadowRoot)).join("\n")); // If the light tree changes, assignment is recomputed. host.children.push(element("em", { slot: "unit" }, text("°F"))); const updated = assignments(host, shadowRoot); console.log("\nafter adding em[slot=unit] to the light tree:"); for (const [name, nodes] of updated) { const before = map.get(name).map(label).join(","); const now = nodes.map(label).join(","); console.log(` slot[${name || "default"}] ${before === now ? "unchanged" : "changed -> " + (now || "(empty)")}`); }
assignments:
slot[label] <- span[slot=label]
slot[default] <- strong
slot[unit] <- span[slot=unit]
unassigned light nodes: span[slot=source]
flattened tree:
#shadow-root
div
span[slot=label] (light)
"Temperature"
output
strong (light)
"-4.2"
span[slot=unit] (light)
"°C"
light tree, empty host:
#shadow-root
div
"Measurement" (fallback)
output
"—" (fallback)
"unitless" (fallback)
after adding em[slot=unit] to the light tree:
slot[label] unchanged
slot[default] unchanged
slot[unit] changed -> span[slot=unit],em[slot=unit]
The line that stands out in the output’s first section is the last one: the child carrying
slot="source" matched no slot and so was not assigned, and therefore not rendered. It has not
been deleted from the tree, it still sits in the document; it just never entered the flattened
tree. This means a component user’s spelling mistakes disappear silently, and it shows the
component’s dependency on documented slot names.
The Flattened Tree
What the browser actually paints is neither the document tree nor the shadow tree; it is the shadow tree with every slot replaced by the nodes assigned to it. This is called the flattened tree, and layout and paint are done over this tree.
The output’s second section shows this. slot elements do not appear in the flattened tree;
nodes from the light tree sit in their place. The strong element is a direct child of the
host in the document tree, but sits inside the output element when painted. The node’s place
in the tree and its place in painting have come apart.
The third section shows the role of fallback content. A slot’s own children are painted when nothing is assigned to it. This gives the component a default appearance: an empty badge in the light tree does not look unit-less and value-less, it shows its placeholders instead. Fallback content is entirely disabled the moment an assignment exists; the two never mix.
The last section shows that a light-tree change recomputes assignment. Slot elements emit an event when their assignments change; a component whose computation depends on its content listens for this event. The Timing and the Rendering Loop lesson’s model gives this event’s place in the rendering loop: it is announced after the code that made the change.
Who Owns Placed Content
Placement does not change a node’s tree. The strong element assigned to a slot stays in the
document tree; it is only moved to a point in the shadow tree while being painted. Because
ownership does not change, three behaviors follow from this.
Style comes from the document. A placed node is styled by the document tree’s rule; shadow tree rules do not generally match it. The component can give placed nodes a limited style: a special selector targets nodes assigned to a slot, matching only the assigned node itself — not its children. This limit is deliberate: the component cannot style the internal structure of content its user gave it, only align its outer box. On conflict the document’s rule wins; the component’s style for placed content is only a suggestion.
Events do not get retargeted. A placed node’s ancestors are in the document tree; an event’s target there is not retargeted, because the event never crossed a shadow boundary. The component cannot and should not see listeners bound to content in the light tree.
The accessibility tree derives from the flattened tree. The structure presented to assistive technology is the order the user sees. Slots’ order in the shadow tree determines reading order, not the write order in the light tree. When designing a component, slot order is not a visual preference, it is a decision that carries meaning.
Summary
- A template element’s content is parsed but does not enter the document; it is inert, loads no resource, and takes no part in painting.
- Template content is duplicated by deep cloning; the copy carries attributes and ids, not listeners or user state.
- The slot assignment rule applies only to the host’s direct children; a name matching nothing is assigned nowhere and is not rendered.
- The rendered structure is the flattened tree: every slot in the shadow tree replaced by its assigned nodes, or by fallback content if there is no assignment.
- A placed node still belongs to the light tree: it is styled by the document’s style, its events do not cross the shadow boundary, and what style the component can give it is limited.
Next Step
Everything built up to here holds once the page has loaded: the element gets defined, the shadow tree gets attached, slots get filled. Behind all of it sits a single assumption — the component’s code, template, and data came from the network. When the network connection weakens or drops entirely, this assumption fails, and the page, however well encapsulated, lands on a blank error screen. Where the station page is used does not favor this: the person taking the measurement is usually at the edge of coverage. The next lesson builds a layer that can meet the page’s requests before they reach the network, and that lives independently of the page.
To keep your progress and take notes, Log in
My notes
Log in to take notes.