Shadow DOM slots, composition
Plenty of components exist to wrap content you hand them: tabs, menus, image galleries, accordions. The component owns the chrome and behavior; you supply what goes inside.
The built-in <select> works this way. It expects you to nest <option> children, and it decides how to draw them. A component you build, say <custom-tabs>, wants the same deal: you pass the tab bodies, it lays them out. A <custom-menu> wants its menu items handed in.
So the code that uses <custom-menu> might read like this:
<custom-menu>
<title>Coffee menu</title>
<item>Espresso</item>
<item>Cold Brew</item>
<item>Flat White</item>
</custom-menu>
The component’s job is then to take that content and render it as a real menu: a styled title, a list of clickable items, open/close behavior, event handling. So how do you actually wire the passed-in content into your component’s internal structure?
One approach: read the element’s children in JavaScript and physically shuffle those DOM nodes into your shadow tree. It can be done, but it fights you. Move a node into shadow DOM and the document’s CSS stops reaching it, so styling breaks. And you’re writing and maintaining the rearranging logic by hand.
There’s a built-in mechanism instead. Shadow DOM ships <slot> elements, and the browser fills them automatically from the light DOM.
Named slots
Start small. Here <user-card> builds a shadow tree with two slots, and each one is filled by matching content from the light DOM.
<script>
customElements.define('user-card', class extends HTMLElement {
connectedCallback() {
this.attachShadow({mode: 'open'});
this.shadowRoot.innerHTML = `
<div>Name:
<slot name="username"></slot>
</div>
<div>Birthday:
<slot name="birthday"></slot>
</div>
`;
}
});
</script>
<user-card>
<span slot="username">Maya Vance</span>
<span slot="birthday">14.03.1998</span>
</user-card>
Inside the shadow DOM, <slot name="X"> marks an insertion point — a labeled spot where any light-DOM element carrying slot="X" will appear.
The browser then does composition: it matches each slot to its light-DOM fillers and renders them in place. The payoff is a component you fill with data by writing plain HTML children.
Here it is running. Open the editor and try changing the names, swapping the slot attributes, or adding a third <span slot="username"> — the card recomposes instantly:
Two separate trees are in play here. Before composition, the DOM structure is just the shadow tree and the light children sitting side by side under the host:
<user-card>
#shadow-root
<div>Name:
<slot name="username"></slot>
</div>
<div>Birthday:
<slot name="birthday"></slot>
</div>
<span slot="username">Maya Vance</span>
<span slot="birthday">14.03.1998</span>
</user-card>
The script attached a shadow root, so it lives under #shadow-root. The host element now carries both a light DOM (the two <span> children) and a shadow DOM (the two <div> wrappers).
To render, the browser walks each <slot name="..."> in the shadow tree and hunts for a light-DOM child with a matching slot="...". Those matched children get drawn inside their slots.
The rendered result is the flattened DOM:
<user-card>
#shadow-root
<div>Name:
<slot name="username">
<!-- slotted element is inserted into the slot -->
<span slot="username">Maya Vance</span>
</slot>
</div>
<div>Birthday:
<slot name="birthday">
<span slot="birthday">14.03.1998</span>
</slot>
</div>
</user-card>
Here’s the key subtlety: the flattened DOM is a rendering-only view. It exists so the browser knows what to paint and how events flow. It’s virtual. The actual nodes never move out of their original positions in the document.
You can confirm it with a query. The light <span> nodes still answer to a selector that looks under <user-card>:
// light DOM <span> nodes are still at the same place, under `<user-card>`
alert( document.querySelectorAll('user-card span').length ); // 2
So flattening derives a combined tree from the shadow DOM by dropping light content into slots. The browser uses that tree for painting, style inheritance, and event propagation (more on that shortly). But scripts see the raw document — light children under the host, shadow children under the shadow root, unmerged.
When several light children name the same slot, they don’t collide — the slot collects them all and renders them in order, one after the next.
So this markup:
<user-card>
<span slot="username">Maya</span>
<span slot="username">Vance</span>
</user-card>
Flattens with both spans stacked inside <slot name="username">:
<user-card>
#shadow-root
<div>Name:
<slot name="username">
<span slot="username">Maya</span>
<span slot="username">Vance</span>
</slot>
</div>
<div>Birthday:
<slot name="birthday"></slot>
</div>
</user-card>
Slot fallback content
Put content between a slot’s tags and it becomes the fallback — the default the browser shows when no light child fills that slot.
Below, Anonymous renders whenever the light DOM has nothing carrying slot="username". Supply a matching child and the fallback disappears, replaced by that child.
<div>Name:
<slot name="username">Anonymous</slot>
</div>
Default slot: the first unnamed one
A <slot> with no name is the default slot. It scoops up every top-level light child that wasn’t claimed by a named slot. Only the first unnamed slot plays this role; any later unnamed slots stay empty.
Add one to <user-card> to catch loose bits of info the author didn’t tag:
<script>
customElements.define('user-card', class extends HTMLElement {
connectedCallback() {
this.attachShadow({mode: 'open'});
this.shadowRoot.innerHTML = `
<div>Name:
<slot name="username"></slot>
</div>
<div>Birthday:
<slot name="birthday"></slot>
</div>
<fieldset>
<legend>Other information</legend>
<slot></slot>
</fieldset>
`;
}
});
</script>
<user-card>
<div>I sketch old maps.</div>
<span slot="username">Maya Vance</span>
<span slot="birthday">14.03.1998</span>
<div>...And I restore bicycles too!</div>
</user-card>
Both untagged <div> children land in the “Other information” fieldset. Since a slot appends its fillers in order, the two loose pieces sit together inside the default slot.
The flattened DOM comes out like this:
<user-card>
#shadow-root
<div>Name:
<slot name="username">
<span slot="username">Maya Vance</span>
</slot>
</div>
<div>Birthday:
<slot name="birthday">
<span slot="birthday">14.03.1998</span>
</slot>
</div>
<fieldset>
<legend>Other information</legend>
<slot>
<div>I sketch old maps.</div>
<div>...And I restore bicycles too!</div>
</slot>
</fieldset>
</user-card>
Menu example
Back to the <custom-menu> from the top of the chapter. Slots handle the content distribution for us.
Here’s how the menu gets used. Note the attributes are slot="title" and slot="item" — those names have to match the slots in the template:
<custom-menu>
<span slot="title">Coffee menu</span>
<li slot="item">Espresso</li>
<li slot="item">Cold Brew</li>
<li slot="item">Flat White</li>
</custom-menu>
And the shadow DOM template, with a slot for each role:
<template id="tmpl">
<style> /* menu styles */ </style>
<div class="menu">
<slot name="title"></slot>
<ul><slot name="item"></slot></ul>
</div>
</template>
Composition wires it up:
<span slot="title">drops into<slot name="title">.- The menu has several
<li slot="item">children, but the template holds just one<slot name="item">. So every<li slot="item">piles into that single slot in order, and together they form the list.
The flattened DOM:
<custom-menu>
#shadow-root
<style> /* menu styles */ </style>
<div class="menu">
<slot name="title">
<span slot="title">Coffee menu</span>
</slot>
<ul>
<slot name="item">
<li slot="item">Espresso</li>
<li slot="item">Cold Brew</li>
<li slot="item">Flat White</li>
</slot>
</ul>
</div>
</custom-menu>
You might squint at that and object: valid HTML says <li> must be a direct child of <ul>, yet here there’s a <slot> in between. That’s fine — this is the flattened rendering view, not the real document tree. In the real tree the <li> elements sit under <custom-menu> as light children, which is legal, and the browser renders them inside the <ul> through the slot. Layout-wise it just works.
All that’s left is a click handler to expand and collapse the list, and <custom-menu> is functional:
customElements.define('custom-menu', class extends HTMLElement {
connectedCallback() {
this.attachShadow({mode: 'open'});
// tmpl is the shadow DOM template (above)
this.shadowRoot.append( tmpl.content.cloneNode(true) );
// we can't select light DOM nodes, so let's handle clicks on the slot
this.shadowRoot.querySelector('slot[name="title"]').onclick = () => {
// open/close the menu
this.shadowRoot.querySelector('.menu').classList.toggle('closed');
};
}
});
The click handler is bound to the shadow-side <slot> element, not to the light <span>. That’s deliberate: from inside the component, the shadow tree is what you can reach with this.shadowRoot.querySelector. Because events bubble up through the flattened DOM, a click on the slotted title still fires this handler.
Here’s the full working demo. Click the title to collapse and expand the list:
From here you can layer on more: custom events, public methods, keyboard support, and so on.
Updating slots
What if outside code adds or removes menu items after the component is already on the page?
The browser watches each slot and re-renders when its assigned elements change.
And because light-DOM nodes are rendered in place rather than copied, editing the text or attributes inside an already-slotted element shows up immediately too. No copy means no stale duplicate to keep in sync.
So rendering stays current on its own. If your component logic needs to react to those changes, listen for the slotchange event.
The example below inserts a new menu item after one second, then rewrites the title after two:
<custom-menu id="menu">
<span slot="title">Coffee menu</span>
</custom-menu>
<script>
customElements.define('custom-menu', class extends HTMLElement {
connectedCallback() {
this.attachShadow({mode: 'open'});
this.shadowRoot.innerHTML = `<div class="menu">
<slot name="title"></slot>
<ul><slot name="item"></slot></ul>
</div>`;
// shadowRoot can't have event handlers, so using the first child
this.shadowRoot.firstElementChild.addEventListener('slotchange',
e => alert("slotchange: " + e.target.name)
);
}
});
setTimeout(() => {
menu.insertAdjacentHTML('beforeEnd', '<li slot="item">Espresso</li>')
}, 1000);
setTimeout(() => {
menu.querySelector('[slot="title"]').innerHTML = "Fresh menu";
}, 2000);
</script>
The rendered menu updates at each step with no manual redraw.
Two slotchange events fire, and one thing pointedly does not fire:
- On init:
slotchange: titlefires right away, because the lightslot="title"element gets assigned into its slot the moment composition happens. - After 1 second:
slotchange: itemfires when the new<li slot="item">is appended, changing which elements are assigned to that slot.
Notice there’s no slotchange after 2 seconds. Rewriting the title’s innerHTML changes what’s inside the already-assigned element — the set of elements assigned to the slot is unchanged, so from the slot’s point of view nothing happened.
Try it yourself. Add item appends a new <li slot="item">, which changes the slot’s assigned set and fires slotchange. Rename title rewrites the existing title’s text — its content changes but the assigned set doesn’t, so no event fires. Watch the two initial events log the moment composition happens:
To catch those internal edits from JavaScript, reach for a broader tool: MutationObserver, which can watch for changes deep inside the light content.
Slot API
To round it out, here are the slot-related JavaScript methods.
As shown, JavaScript reads the “real” unflattened DOM. Still, when the shadow tree is {mode: 'open'}, you can ask which elements landed in a slot, and inversely, which slot an element ended up in:
node.assignedSlot— the<slot>element thatnodewas assigned to (ornullif none).slot.assignedNodes({flatten: true/false})— the DOM nodes assigned to the slot.flattendefaults tofalse. Set it totrueto descend through the flattened DOM: it resolves nested slots when components are composed inside each other, and returns the fallback content when nothing was assigned.slot.assignedElements({flatten: true/false})— same idea, but element nodes only (text and comment nodes filtered out).
These earn their keep when the component needs to know its content, not merely display it.
Say <custom-menu> wants to keep a live list of what it’s showing. It listens for slotchange and reads the current items from slot.assignedElements:
<custom-menu id="menu">
<span slot="title">Coffee menu</span>
<li slot="item">Espresso</li>
<li slot="item">Cold Brew</li>
</custom-menu>
<script>
customElements.define('custom-menu', class extends HTMLElement {
items = []
connectedCallback() {
this.attachShadow({mode: 'open'});
this.shadowRoot.innerHTML = `<div class="menu">
<slot name="title"></slot>
<ul><slot name="item"></slot></ul>
</div>`;
// triggers when slot content changes
this.shadowRoot.firstElementChild.addEventListener('slotchange', e => {
let slot = e.target;
if (slot.name == 'item') {
this.items = slot.assignedElements().map(elem => elem.textContent);
alert("Items: " + this.items);
}
});
}
});
// items update after 1 second
setTimeout(() => {
menu.insertAdjacentHTML('beforeEnd', '<li slot="item">Flat White</li>')
}, 1000);
</script>
Each time the item slot’s assignment changes, the handler rebuilds this.items from the current elements. The component always has an accurate picture of what it renders.
Summary
When an element has a shadow DOM, its light DOM normally isn’t displayed on its own. Slots are how you surface chosen pieces of that light DOM at specific spots inside the shadow tree.
Two kinds of slots:
- Named slot:
<slot name="X">...</slot>— collects top-level light children withslot="X". - Default slot: the first nameless
<slot>(later nameless slots are ignored) — collects the top-level light children that no named slot claimed. - Multiple matches for one slot are appended in order.
- Content written inside a
<slot>is its fallback, shown only when no light child fills it.
Rendering slotted elements inside their slots is called composition, and the merged view it produces is the flattened DOM.
Composition doesn’t actually move nodes. From JavaScript’s perspective, the document tree is unchanged — light under the host, shadow under the root.
JavaScript can inspect slots (with an open shadow tree):
slot.assignedNodes/Elements()— the nodes or elements sitting in the slot.node.assignedSlot— the reverse lookup, the slot a node was placed in.
To track what’s on display:
- The
slotchangeevent — fires when a slot is first filled and on any add, remove, or replace of its assigned elements, but not on edits inside those elements. The changed slot isevent.target. - MutationObserver — to watch deeper, inside the slotted content itself.
Now that light-DOM content can be shown inside shadow DOM, the next question is styling it. The default split is that shadow elements are styled from within the component and light elements from outside, with a few important exceptions. The next chapter digs into those.