Step 06

Slots and templates

Steps 02 through 05 built an element that owns everything inside it. That is fine until somebody wants to put their markup in it — a link in the header, an icon next to the title, another one of your components in the footer. Attributes cannot carry markup, so the answer is not another attribute. It is a hole in the component that the consumer fills. This step covers the two pieces that make that work: <template>, which lets you keep inert markup in the document and stamp copies of it, and <slot>, which decides where the consumer's own nodes render. Then the design question the two of them raise: when is something an attribute, and when is it a slot?

Learning Objectives

  • Use <template> to hold inert markup and stamp clones of it
  • Distinguish named slots from the default slot, and use fallback content
  • React to assignment changes with slotchange
  • Tell composition from configuration when designing an element's API

<template> is inert markup

A <template> element is parsed like any other markup — the parser reads it, validates it, and builds real nodes — but it is never rendered. Its children do not go into the document tree at all. They go into a separate DocumentFragment hanging off the element's .content property, which lives outside the main tree.

That "outside the main tree" part is the whole feature, and it is more than a display trick. Scripts inside a template do not run. Images inside it do not load. Custom elements inside it are not upgraded. Nothing in there costs anything until you clone it into a tree that is actually being rendered. A <div hidden> full of markup does none of that: the browser still fetches its images and still runs its custom element definitions, it just refuses to paint the result.

<template id="card-template"> <article> <header><slot name="title">Untitled</slot></header> <slot></slot> </article> </template>

To use it, clone the fragment. Always cloneNode(true) — never append .content itself. Appending a DocumentFragment moves its children out of it and leaves the fragment empty, so the first instance of your component would render and every instance after it would get nothing. Cloning keeps the template reusable, which is the reason you put the markup in a template in the first place.

const template = document.getElementById('card-template'); class InfoCard extends HTMLElement { connectedCallback() { if (this.shadowRoot) return; // root already built — do not rebuild this.attachShadow({ mode: 'open' }) .append(template.content.cloneNode(true)); } }

The template does not have to come from the document. The demo later in this step builds one with document.createElement('template') and a string of markup, which is the usual shape when a component ships as a single JavaScript module and cannot assume the page contains anything in particular. Either way, the parse happens once and every instance clones the result.

💡 Parse once: building the template at module scope means the markup is parsed a single time no matter how many instances the page creates. Setting innerHTML inside connectedCallback, as several earlier steps did for brevity, re-parses that string for every single element.

Slots project, they do not move

This is the sentence to hold on to: a <slot> changes where a node renders, not where it lives. When you write markup inside a custom element that has a shadow root, those nodes stay exactly where you put them — in the light DOM, as children of the host element. The shadow tree declares a spot, and the browser paints them there. Nothing is moved, reparented, or copied.

Every consequence follows from that. card.children still lists what you wrote. card.querySelector('p') still finds the paragraph. Page-level CSS still styles it, because it is still an ordinary element in the ordinary document — which is exactly why step 04's ::slotted() exists at all. If slotted nodes really moved into the shadow tree, the page would have lost the ability to style them and there would be nothing for ::slotted() to do.

const card = document.querySelector('info-card'); card.children.length; // 2 — the span and the p card.querySelector('p').parentElement === card; // true // The shadow tree only decided *where* those nodes render. card.shadowRoot.querySelector('slot:not([name])').assignedElements(); // [ <p> ]

Projection is not relocation

Slotted content is rendered in the shadow tree and owned by the light DOM. It never becomes a child of the shadow root, it never leaves the host element, and the document's own selectors and stylesheets keep working on it. If you find yourself reaching into shadowRoot to find markup a consumer wrote, you are looking in the wrong tree: ask the slot for it with assignedElements(), or just query the host.

Named and default slots, and fallback

A shadow tree may contain one unnamed <slot>, and that one is the default: any child of the host without a slot attribute is assigned to it. Give a slot a name and a child opts in by matching it with slot="name". Children whose slot value matches no slot in the tree are assigned nowhere and simply do not render.

<info-card> <span slot="title">Ada Lovelace</span> <p>First published algorithm intended for a machine.</p> </info-card>

The <span> goes to slot="title"; the <p> has no slot attribute, so it lands in the default slot. Note that the order of the children in the markup does not have to match the order of the slots in the shadow tree — assignment is by name, and the shadow tree decides layout.

Anything you write between a slot's tags is fallback content. It renders only while nothing is assigned to that slot, and disappears the moment something is. In the template above, Untitled shows until the consumer supplies a title. Fallback is the cheapest way to make a component usable with no configuration at all, and it costs one line.

Fallback is not a default value

Fallback content is markup the component renders on the consumer's behalf; it is not stored anywhere, it is not readable as a property, and assignedElements() returns an empty array while the fallback is showing. If you need to know whether a slot is filled, count the assigned elements — with no options, because { flatten: true } counts the fallback itself and would report an empty slot as full. Do not try to inspect what the fallback rendered either way.

Reacting to slotchange

Slot assignment is live. A consumer can append, remove, or re-slot a child at any time, and the shadow tree has to keep up. The slotchange event fires on a slot when its set of assigned nodes changes, including the very first assignment when the element is first populated — so a listener attached in connectedCallback will hear about the initial content, not just later edits.

Two accessors read what a slot currently has. assignedElements() returns just the elements, which is what you want most of the time. assignedNodes() returns nodes, including text nodes and whitespace. Both take the same { flatten: true } option, and it does two things: it resolves nested slots — if the assigned content is itself a slot in another component's tree, flatten follows it to the real nodes — and, when nothing is assigned at all, it returns the slot's fallback content instead of an empty array. That second behavior is the one that surprises people, so keep it out of any check that is really asking "is this slot filled?".

const slot = this.shadowRoot.querySelector('slot[name="actions"]'); slot.addEventListener('slotchange', () => { const count = slot.assignedElements().length; this.toggleAttribute('has-actions', count > 0); });

That toggled attribute is the useful trick. The component cannot style an empty footer out of existence with CSS alone, but it can reflect "somebody gave me actions" onto the host as an attribute, and then :host(:not([has-actions])) footer { display: none; } inside the shadow tree does the rest. A slot's occupancy becomes a styling hook.

💡 What slotchange actually tells you: that the assignment changed — a node was added, removed, or reassigned. It does not fire when the assigned content mutates internally. If a consumer edits the text inside a slotted <p>, the assignment is identical and no event fires. Watching for that needs a MutationObserver on the assigned nodes, which is a much heavier commitment — be sure you need it.

Demo: slot composition

The card below has a named title slot, a default slot for its body, and a named actions slot whose footer is hidden until something is assigned to it. Each button changes the light DOM — appending a button with slot="actions", removing them again, appending a plain paragraph — and every one of those edits produces a slotchange entry in the log. Watch which slot fires: adding a paragraph does not disturb the actions slot, and adding an action does not disturb the default one.

Composition versus configuration

Every component API is a series of decisions about which of these two an input is. Get it wrong in the configuration direction and consumers fight you with attribute values that want to be markup. Get it wrong in the composition direction and a component that should have taken a string demands three lines of boilerplate.

⚙️ Attributes configure

An attribute carries a scalar — a string, or a string parsed into a number or boolean. It is part of the element's declared API surface: you can observe it, reflect it, validate it, and match it in CSS with :host([variant="danger"]). The component decides what the value means.

<info-card variant="danger" label="Save"> </info-card>

🧩 Slots compose

A slot carries arbitrary markup that the consumer owns — text, elements, links, icons, even other components. The component never inspects it or decides what it means; it only decides where it goes. Nothing about it is reflectable, because it is not a value.

<info-card> <span slot="label">Save <kbd>⌘S</kbd></span> </info-card>

The heuristic

If a consumer would ever plausibly want to put a link, an icon, or another component in it, it is a slot — not an attribute. label="Save" is configuration and reads perfectly until the day somebody needs Save ⌘S in that spot, at which point an attribute has no answer that is not an XSS hole.

The two are not exclusive, and the best components use both on the same concept: a named slot for the rich case, with fallback content that renders an attribute for the simple one. Consumers who want a plain string set the attribute and never think about slots; consumers who want markup fill the slot and the fallback vanishes on its own.

⚠️ Do not build markup out of attribute strings

The wrong escape from "an attribute cannot hold markup" is to let it hold markup anyway — taking getAttribute('title') and splicing it into an innerHTML string. That is a script-injection hole with extra steps, and the platform already has the right answer sitting next to it. Slot it.

Next Steps

In Step 7: Styling and theming, we'll explore:

  • Designing a theming surface consumers can rely on
  • Dark mode across the shadow boundary
  • adoptedStyleSheets and sharing one stylesheet across every instance
  • Why ::part is a versioning hazard