Shadow DOM Slots, Composition
Learn slots and composition in the JavaScript Shadow DOM: default and named slots, light DOM vs. shadow DOM, the flattened tree, and the slotchange event.
Slots and composition are what make Shadow DOM genuinely reusable. A component author writes a fixed internal structure once, and consumers fill it with their own markup — without the two ever colliding. This page covers the <slot> element (default and named slots), how the light DOM and shadow DOM combine into a flattened tree, the slotchange event, and the assignedNodes() / assignedElements() methods you use to read what landed in a slot.
If you are new to Shadow DOM, read Shadow DOM first for the basics of attachShadow() and shadow roots, and Web Components for how slots fit alongside custom elements and templates.
Light DOM vs. shadow DOM
Composition involves two trees:
- Light DOM — the markup the user writes between your element's tags:
<my-card>...this part...</my-card>. It lives in the regular document and stays there. - Shadow DOM — the markup you attach with
attachShadow(). It is encapsulated and not directly reachable from the outside document.
A <slot> is a window: it sits in the shadow DOM and projects light-DOM children into it. The light-DOM nodes are not moved — they are only displayed at the slot's position. This combined view is the flattened tree, and it is what the browser actually renders and styles.
Understanding slots in Shadow DOM
A slot is a placeholder in your shadow DOM where the browser drops content supplied from the light DOM. Slots are how a generic component lets each instance look different while sharing one internal template.
Defining a default slot
Use the <slot> element. A slot with no name attribute is the default slot: it catches any light-DOM child that has no slot attribute. Text inside <slot> is fallback content, shown only when nothing is assigned.
<body>
<script>
class CustomElement extends HTMLElement {
constructor() {
super();
const shadowRoot = this.attachShadow({ mode: 'open' });
shadowRoot.innerHTML = `
<div class="container">
<slot>Default content</slot>
</div>
`;
}
}
customElements.define('custom-element', CustomElement);
</script>
<!-- No children: the slot shows its fallback, "Default content" -->
<custom-element></custom-element>
<!-- Children with no slot attribute go into the default slot -->
<custom-element><strong>Hello from the light DOM!</strong></custom-element>
</body>The first <custom-element> renders "Default content" because nothing was assigned. The second renders the bold text — its light-DOM child replaces the fallback. Note the markup still lives in the document; the slot only displays it.
Named slots
When a component has more than one insertion point, give each <slot> a name and match it from the light DOM with a slot="..." attribute. This is how you route the right content to the right place.
<body>
<!-- Define Custom Element -->
<script>
// Define Custom Element Class
class CustomElement extends HTMLElement {
constructor() {
super();
const shadowRoot = this.attachShadow({ mode: 'open' });
shadowRoot.innerHTML = `
<style>
/* Define styles for the component */
.container {
border: 1px solid #ccc;
padding: 20px;
}
</style>
<div class="container">
<slot name="content">Default content</slot>
</div>
`;
}
}
// Define Custom Element
customElements.define('custom-element', CustomElement);
</script>
<!-- Displaying the custom element -->
<custom-element>
<div slot="content">Content from parent</div>
</custom-element>
</body>The <div slot="content"> is matched to <slot name="content">, so "Content from parent" replaces the fallback. Anything not matching a named slot would fall through to a default slot, if one exists, or simply not render.
Enhancing composition in Shadow DOM
Composition in the context of Shadow DOM refers to assembling UI components and content by combining slots and their distributed content to create more complex, reusable structures. When applied within the context of Shadow DOM, composition enables the creation of highly customizable and reusable web components.
To style content distributed into slots from the parent, use the ::slotted() CSS pseudo-element — for example, ::slotted(div) { color: blue; }. See Shadow DOM Styling for the full picture of ::slotted(), :host, and CSS custom properties.
Composing components with slots
One powerful way to leverage composition is to combine multiple slots into a structured layout. Here a composite component defines header, content, and footer regions:
<body>
<script>
// Define Composite Element Class
class CompositeElement extends HTMLElement {
constructor() {
super();
const shadowRoot = this.attachShadow({ mode: 'open' });
shadowRoot.innerHTML = `
<style>
/* Define styles for the composite component */
.container {
border: 1px solid #ccc;
padding: 20px;
}
</style>
<div class="container">
<slot name="header"></slot>
<slot name="content"></slot>
<slot name="footer"></slot>
</div>
`;
}
}
// Define Composite Element
customElements.define('composite-element', CompositeElement);
</script>
<composite-element>
<div slot="header">Header</div>
<div slot="content">Content</div>
<div slot="footer">Footer</div>
</composite-element>
</body>Each slot="..." child is routed to its matching named slot, producing a clean header/content/footer layout that any instance can fill differently.
Reacting to slot changes with slotchange
Slotted content is dynamic: a consumer can add, remove, or replace light-DOM children at any time. The slotchange event fires on a <slot> whenever its assigned nodes change, so your component can react — re-render a summary, validate, lazy-load, and so on. Listen for it from inside the shadow root:
<body>
<script>
class TabList extends HTMLElement {
constructor() {
super();
const root = this.attachShadow({ mode: 'open' });
root.innerHTML = `<p id="count"></p><slot></slot>`;
this._slot = root.querySelector('slot');
this._count = root.querySelector('#count');
}
connectedCallback() {
this._slot.addEventListener('slotchange', () => this.update());
this.update();
}
update() {
// assignedElements() returns only element nodes in the slot
const items = this._slot.assignedElements();
this._count.textContent = `Tabs: ${items.length}`;
}
}
customElements.define('tab-list', TabList);
</script>
<tab-list>
<button>One</button>
<button>Two</button>
</tab-list>
<script>
// Adding a child later fires slotchange → count updates to 3
const list = document.querySelector('tab-list');
const extra = document.createElement('button');
extra.textContent = 'Three';
list.appendChild(extra);
</script>
</body>Initially the component shows "Tabs: 2". When the third <button> is appended, slotchange fires and the count updates to "Tabs: 3".
Reading slotted content: assignedNodes() vs. assignedElements()
Both methods are called on a <slot> and return what the browser assigned to it from the light DOM:
slot.assignedNodes()returns all nodes — elements and text nodes (including whitespace between tags).slot.assignedElements()returns only element nodes. This is usually what you want.
Pass { flatten: true } to descend into nested slots when slots are chained across components:
// All nodes, including stray text/whitespace nodes
slot.assignedNodes(); // e.g. [text, <button>, text, <button>, text]
// Elements only — cleaner for iteration
slot.assignedElements(); // e.g. [<button>, <button>]
// Flatten through nested <slot> assignments
slot.assignedElements({ flatten: true });Prefer assignedElements() unless you specifically need the text nodes; it spares you from filtering out whitespace.
The flattened tree, recapped
The browser does not literally move light-DOM nodes into the shadow DOM. Instead it builds a flattened tree by substituting each slot with its assigned nodes for rendering and styling. Practical consequences:
- Slotted elements stay in the document, so
document.querySelector()still finds them and their originalclass/idstill apply. - They are styled by the page's CSS, while the component reaches them only via
::slotted(). - Event listeners attached in the light DOM keep working — events bubble through the flattened tree.
Conclusion
Slots and composition turn an encapsulated shadow DOM into a flexible, reusable component: you define the structure, and consumers supply the content through default and named slots. Remember the key pieces — light DOM vs. shadow DOM, the flattened tree the browser renders, the slotchange event for reacting to changes, and assignedElements() for reading what was slotted.
To go further, see Web Components for the bigger picture, Custom Elements for the element lifecycle, Shadow DOM Styling for ::slotted() and :host, and Shadow DOM for the fundamentals.