Skip to main content

How to Use Shadow DOM Slots and Composition in JavaScript

Web components become truly flexible when they allow consumers to inject their own content into predefined locations. This is exactly what slots provide. Slots are placeholders inside a component's Shadow DOM that get filled with content from the Light DOM, creating a powerful composition model. Instead of hardcoding every piece of content inside the shadow tree, you define "holes" where outside content can be projected in. In this guide, you will learn how named and default slots work, how to provide fallback content when no outside content is supplied, how to react to slot content changes with the slotchange event, and how to programmatically inspect slotted content using assignedNodes() and assignedElements().

The Concept of Slot-Based Composition​

Before diving into the API, it helps to understand the problem that slots solve.

Imagine you build a <modal-dialog> component. You want the modal to have a consistent look (border, shadow, backdrop, close button), but the title and body content should be different every time it is used. Without slots, you would have to pass everything through attributes or JavaScript properties:

// Without slots - awkward and limited
const modal = document.createElement('modal-dialog');
modal.setAttribute('title', 'Confirm Delete');
modal.setAttribute('body', 'Are you sure?'); // What about rich HTML content?

Attributes only accept strings. You cannot pass HTML structures, interactive elements, or nested components through attributes. Slots solve this elegantly:

<!-- With slots - natural and flexible -->
<modal-dialog>
<span slot="title">Confirm Delete</span>
<div slot="body">
<p>Are you sure you want to delete <strong>project-alpha</strong>?</p>
<p>This action cannot be undone.</p>
</div>
</modal-dialog>

The component author defines where content goes. The component consumer decides what content to provide.

Named Slots​

Named slots are placeholders in the Shadow DOM that accept specific Light DOM elements identified by their slot attribute.

Basic Syntax​

In the Shadow DOM, you declare a named slot:

<slot name="header"></slot>

In the Light DOM, you assign content to that slot:

<my-component>
<h2 slot="header">This goes into the header slot</h2>
</my-component>

The browser takes the Light DOM element with slot="header" and visually projects it into the <slot name="header"> placeholder inside the shadow tree.

A Complete Named Slots Example​

const template = document.createElement('template');
template.innerHTML = `
<style>
.card {
border: 1px solid #ddd;
border-radius: 8px;
overflow: hidden;
max-width: 400px;
font-family: system-ui, sans-serif;
}
.card-header {
background: #2c3e50;
color: white;
padding: 16px;
font-size: 18px;
}
.card-body {
padding: 16px;
color: #333;
line-height: 1.6;
}
.card-footer {
background: #f8f9fa;
padding: 12px 16px;
border-top: 1px solid #eee;
text-align: right;
}
</style>
<div class="card">
<div class="card-header">
<slot name="header"></slot>
</div>
<div class="card-body">
<slot name="body"></slot>
</div>
<div class="card-footer">
<slot name="footer"></slot>
</div>
</div>
`;

class ContentCard extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.appendChild(template.content.cloneNode(true));
}
}

customElements.define('content-card', ContentCard);
<content-card>
<span slot="header">Project Update</span>
<div slot="body">
<p>The deployment was successful.</p>
<p>All tests passed with <strong>100% coverage</strong>.</p>
</div>
<span slot="footer">
<button>Dismiss</button>
<button>View Details</button>
</span>
</content-card>

What happens visually:

The browser renders the card with the shadow tree structure (the styled .card container), but the actual content inside each section comes from the Light DOM elements that match each named slot.

Rendered (Flattened) Tree:
┌──────────────────────────────────┐
│ <div class="card"> │
│ <div class="card-header"> │
│ "Project Update" │ ← from slot="header"
│ </div> │
│ <div class="card-body"> │
│ <p>The deployment...</p> │ ← from slot="body"
│ <p>All tests passed...</p> │
│ </div> │
│ <div class="card-footer"> │
│ <button>Dismiss</button> │ ← from slot="footer"
│ <button>View Details</button>│
│ </div> │
└──────────────────────────────────┘

Multiple Elements in the Same Slot​

More than one Light DOM element can target the same named slot. They appear in the order they are written:

<content-card>
<h3 slot="header">Main Title</h3>
<span slot="header">Subtitle text</span>

<p slot="body">First paragraph.</p>
<p slot="body">Second paragraph.</p>
<p slot="body">Third paragraph.</p>
</content-card>

Both <h3> and <span> are projected into the header slot. All three <p> elements are projected into the body slot, in order.

Slot Assignment Rules​

Only direct children of the host element can be assigned to slots. Nested elements cannot target slots:

<content-card>
<!-- This works: direct child with slot attribute -->
<span slot="header">Works!</span>

<!-- This does NOT work: nested element cannot reach the slot -->
<div>
<span slot="header">Does not get slotted!</span>
</div>
</content-card>
// Verification
const card = document.querySelector('content-card');
const headerSlot = card.shadowRoot.querySelector('slot[name="header"]');
console.log(headerSlot.assignedNodes());
// Only the direct child <span> appears, not the nested one
warning

The slot attribute only works on direct children of the custom element (the shadow host). If you wrap an element in another element, it loses its ability to be assigned to a slot. This is one of the most common mistakes when working with slots.

Default (Unnamed) Slot​

A slot without a name attribute is called the default slot. It captures all Light DOM children that do not have a slot attribute (or whose slot attribute does not match any named slot).

Basic Usage​

const template = document.createElement('template');
template.innerHTML = `
<style>
.wrapper {
border: 2px solid #3498db;
border-radius: 8px;
padding: 16px;
}
.title {
color: #2c3e50;
margin-bottom: 12px;
font-size: 20px;
font-weight: bold;
}
.content {
color: #555;
line-height: 1.6;
}
</style>
<div class="wrapper">
<div class="title">
<slot name="title"></slot>
</div>
<div class="content">
<slot></slot> <!-- Default slot: no name attribute -->
</div>
</div>
`;

class SimplePanel extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.appendChild(template.content.cloneNode(true));
}
}

customElements.define('simple-panel', SimplePanel);
<simple-panel>
<span slot="title">Getting Started</span>

<!-- These have no slot attribute, so they go to the default slot -->
<p>Welcome to the application.</p>
<p>Follow these steps to set up your account.</p>
<ul>
<li>Step 1: Create a profile</li>
<li>Step 2: Configure preferences</li>
<li>Step 3: Invite your team</li>
</ul>
</simple-panel>

The <span slot="title"> goes into the named title slot. Everything else (the two <p> elements and the <ul>) goes into the default slot because they do not have a slot attribute.

What Happens to Unmatched Content​

If there is no default slot in the shadow tree, any Light DOM children without a matching slot attribute are simply not displayed:

const template = document.createElement('template');
template.innerHTML = `
<style>
.box { border: 1px solid #ccc; padding: 12px; }
</style>
<div class="box">
<slot name="title"></slot>
<!-- No default slot! -->
</div>
`;

class NoDefaultSlot extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.appendChild(template.content.cloneNode(true));
}
}

customElements.define('no-default-slot', NoDefaultSlot);
<no-default-slot>
<h2 slot="title">Visible Title</h2>
<p>This paragraph is NOT displayed because there is no default slot.</p>
<p>Neither is this one.</p>
</no-default-slot>

Only the title appears. The paragraphs still exist in the Light DOM (you can access them via JavaScript), but they have nowhere to be projected, so they remain invisible.

Only One Default Slot​

A shadow tree should have at most one default slot. If you place multiple unnamed slots, only the first one receives the content:

// Not recommended: multiple default slots
shadow.innerHTML = `
<div class="section-a">
<slot></slot> <!-- This one gets the content -->
</div>
<div class="section-b">
<slot></slot> <!-- This one stays empty -->
</div>
`;

Multiple named slots are perfectly fine, but duplicate default slots lead to confusing behavior. Stick to one.

Fallback Content​

Slots can contain fallback content, which is the HTML placed between the opening and closing <slot> tags. This fallback is displayed only when no Light DOM content is provided for that slot.

Basic Fallback​

const template = document.createElement('template');
template.innerHTML = `
<style>
.profile {
display: flex;
align-items: center;
gap: 16px;
padding: 16px;
border: 1px solid #e0e0e0;
border-radius: 8px;
}
.avatar {
width: 60px;
height: 60px;
border-radius: 50%;
background: #bdc3c7;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
color: white;
}
.name { font-size: 18px; font-weight: bold; color: #2c3e50; }
.bio { font-size: 14px; color: #7f8c8d; }
</style>
<div class="profile">
<div class="avatar">
<slot name="avatar">👤</slot>
</div>
<div>
<div class="name">
<slot name="name">Anonymous User</slot>
</div>
<div class="bio">
<slot name="bio">No bio provided.</slot>
</div>
</div>
</div>
`;

class UserProfile extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.appendChild(template.content.cloneNode(true));
}
}

customElements.define('user-profile', UserProfile);
<!-- Full content provided -->
<user-profile>
<span slot="avatar">🧑‍đŸ’ģ</span>
<span slot="name">Alice Chen</span>
<span slot="bio">Full-stack developer and open-source contributor.</span>
</user-profile>

<!-- Partial content: only name provided -->
<user-profile>
<span slot="name">Bob Smith</span>
</user-profile>

<!-- No content at all: all fallbacks shown -->
<user-profile></user-profile>

What renders for each:

The first profile shows all custom content. The second profile shows "Bob Smith" as the name, but uses the fallback "👤" for the avatar and "No bio provided." for the bio. The third profile shows all three fallback values.

Fallback with Rich HTML​

Fallback content is not limited to text. You can include full HTML structures:

shadow.innerHTML = `
<div class="content">
<slot name="actions">
<p style="color: #999; font-style: italic;">
No actions available.
<a href="/help">Learn more</a> about adding actions.
</p>
</slot>
</div>
`;

If no element with slot="actions" is provided, the entire fallback paragraph with the link is displayed.

Fallback Behavior Rules​

Fallback content follows a simple rule: it is shown when the slot has zero assigned nodes. The moment any Light DOM element targets that slot, the fallback disappears completely:

<user-profile>
<!-- Even an empty span replaces the fallback -->
<span slot="name"></span>
</user-profile>

In this case, the name slot shows nothing (an empty span), not the "Anonymous User" fallback. The fallback is only used when no element at all targets the slot.

tip

If you want to conditionally show fallback content based on whether the slotted content is "empty" (e.g., an empty string), you will need JavaScript logic using assignedNodes() to inspect the actual content. The browser does not distinguish between an empty element and a meaningful one when determining fallback display.

The slotchange Event​

The slotchange event fires on a <slot> element whenever the set of nodes assigned to that slot changes. This allows your component to react when the consumer adds, removes, or swaps Light DOM content.

When Does slotchange Fire?​

The event fires when:

  • Light DOM elements with matching slot attributes are added to or removed from the host.
  • An element's slot attribute is changed to or from the slot's name.
  • An element assigned to the slot is moved in or out of the host's children.

The event does not fire when:

  • The content inside a slotted element changes (e.g., updating textContent of an already-slotted element).
  • Attributes other than slot change on a slotted element.

Basic Example​

const template = document.createElement('template');
template.innerHTML = `
<style>
.container { border: 1px solid #ccc; padding: 12px; border-radius: 6px; }
.status { color: #888; font-size: 12px; margin-top: 8px; }
</style>
<div class="container">
<slot name="items"></slot>
</div>
<div class="status"></div>
`;

class ItemList extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.appendChild(template.content.cloneNode(true));

const slot = shadow.querySelector('slot[name="items"]');
const status = shadow.querySelector('.status');

slot.addEventListener('slotchange', () => {
const items = slot.assignedElements();
status.textContent = `${items.length} item(s) in the list.`;
console.log('Slot content changed! Items:', items.length);
});
}
}

customElements.define('item-list', ItemList);
<item-list id="mylist">
<div slot="items">Item A</div>
<div slot="items">Item B</div>
</item-list>

<button id="add-btn">Add Item</button>
<button id="remove-btn">Remove Last Item</button>

<script>
const list = document.getElementById('mylist');
let counter = 2;

document.getElementById('add-btn').addEventListener('click', () => {
counter++;
const newItem = document.createElement('div');
newItem.slot = 'items';
newItem.textContent = `Item ${String.fromCharCode(64 + counter)}`;
list.appendChild(newItem);
// This triggers slotchange!
});

document.getElementById('remove-btn').addEventListener('click', () => {
const lastItem = list.querySelector('[slot="items"]:last-of-type');
if (lastItem) {
lastItem.remove();
counter--;
// This also triggers slotchange!
}
});
</script>

Console output on page load:

Slot content changed! Items: 2

After clicking "Add Item":

Slot content changed! Items: 3

After clicking "Remove Last Item":

Slot content changed! Items: 2

slotchange Does Not Bubble by Default​

The slotchange event has bubbles: true but composed: false. This means it bubbles within the shadow tree but does not cross the shadow boundary:

// Listening inside the shadow tree - WORKS
shadow.addEventListener('slotchange', (e) => {
console.log('Caught via bubbling inside shadow:', e.target);
});

// Listening on the host element from outside - DOES NOT WORK
host.addEventListener('slotchange', (e) => {
console.log('This never fires');
});
info

Since slotchange is composed: false, you must listen for it inside the shadow root, not on the host element from outside. This is by design: slot changes are internal implementation details that the component manages, not something the consumer needs to observe directly.

Content Changes vs. Slot Assignment Changes​

A critical distinction: slotchange fires when elements are assigned or unassigned to a slot, not when the content of already-slotted elements changes.

<my-component>
<span slot="title" id="title-span">Original Title</span>
</my-component>

<script>
// This does NOT trigger slotchange:
document.getElementById('title-span').textContent = 'Updated Title';

// This DOES trigger slotchange:
document.getElementById('title-span').removeAttribute('slot');

// This also triggers slotchange:
document.getElementById('title-span').setAttribute('slot', 'title');
</script>

If you need to react to content changes inside slotted elements, use a MutationObserver on the assigned nodes instead.

Practical Use Case: Counting and Validating Children​

class TabPanel extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
.tabs { display: flex; border-bottom: 2px solid #ddd; }
.error { color: #e74c3c; padding: 8px; font-size: 14px; }
::slotted([slot="tab"]) {
padding: 8px 16px;
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
}
</style>
<div class="tabs">
<slot name="tab"></slot>
</div>
<div class="error" hidden></div>
<div class="panel">
<slot name="panel"></slot>
</div>
`;

const tabSlot = shadow.querySelector('slot[name="tab"]');
const panelSlot = shadow.querySelector('slot[name="panel"]');
const errorDiv = shadow.querySelector('.error');

const validate = () => {
const tabs = tabSlot.assignedElements();
const panels = panelSlot.assignedElements();

if (tabs.length !== panels.length) {
errorDiv.textContent =
`Mismatch: ${tabs.length} tab(s) but ${panels.length} panel(s).`;
errorDiv.hidden = false;
} else {
errorDiv.hidden = true;
}
};

tabSlot.addEventListener('slotchange', validate);
panelSlot.addEventListener('slotchange', validate);
}
}

customElements.define('tab-panel', TabPanel);

The component validates that the number of tabs matches the number of panels whenever slot content changes.

assignedNodes() and assignedElements()​

These methods allow you to programmatically inspect what content has been projected into a slot.

assignedNodes()​

Returns an array of all nodes (including text nodes and comment nodes) assigned to the slot.

<my-widget>
<span slot="content">Hello</span>
World
<!-- a comment -->
</my-widget>
class MyWidget extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<slot name="content"></slot>
<slot></slot>
`;

const namedSlot = shadow.querySelector('slot[name="content"]');
const defaultSlot = shadow.querySelector('slot:not([name])');

// Wait for slot assignment
namedSlot.addEventListener('slotchange', () => {
const nodes = namedSlot.assignedNodes();
console.log('Named slot nodes:', nodes);
// Output: [<span>Hello</span>]

nodes.forEach(node => {
console.log(` Type: ${node.nodeType}, Content: "${node.textContent}"`);
});
// Output: Type: 1, Content: "Hello"
});

defaultSlot.addEventListener('slotchange', () => {
const nodes = defaultSlot.assignedNodes();
console.log('Default slot nodes:', nodes);
// Includes text nodes ("World", whitespace) and comment nodes

nodes.forEach(node => {
console.log(` Type: ${node.nodeType}, Content: "${node.textContent.trim()}"`);
});
// Output may include:
// Type: 3, Content: "World" (text node)
// Type: 8, Content: "a comment" (comment node)
// Plus whitespace text nodes
});
}
}

customElements.define('my-widget', MyWidget);

assignedElements()​

Returns an array of only element nodes assigned to the slot, filtering out text nodes and comments:

namedSlot.addEventListener('slotchange', () => {
const elements = namedSlot.assignedElements();
console.log('Named slot elements:', elements);
// Output: [<span>Hello</span>]

// Much cleaner - no text nodes or comments
elements.forEach(el => {
console.log(` Tag: ${el.tagName}, Text: "${el.textContent}"`);
});
// Output: Tag: SPAN, Text: "Hello"
});

In most practical scenarios, assignedElements() is what you want. Use assignedNodes() only when you specifically need access to text nodes.

The { flatten: true } Option​

Both methods accept an options object with a flatten property. When flatten is true, if the slot has no assigned content (meaning the fallback is showing), the method returns the fallback nodes instead of an empty array.

const template = document.createElement('template');
template.innerHTML = `
<slot name="header">
<span class="default-header">Default Title</span>
</slot>
`;

class FlattenDemo extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.appendChild(template.content.cloneNode(true));

const slot = shadow.querySelector('slot[name="header"]');

// Wait a tick for slot assignment
setTimeout(() => {
// Without flatten: empty array (no Light DOM content assigned)
console.log('Without flatten:', slot.assignedNodes());
// Output: []

// With flatten: returns the fallback content
console.log('With flatten:', slot.assignedNodes({ flatten: true }));
// Output: [<span class="default-header">Default Title</span>]

console.log('Elements with flatten:', slot.assignedElements({ flatten: true }));
// Output: [<span class="default-header">Default Title</span>]
});
}
}

customElements.define('flatten-demo', FlattenDemo);
<!-- No content provided for the header slot -->
<flatten-demo></flatten-demo>

Without { flatten: true }, the methods tell you what Light DOM content was projected. With { flatten: true }, the methods tell you what is actually displayed in the slot, whether that is projected content or fallback content.

Practical Example: Dynamic Rendering Based on Slot Content​

Here is a component that adjusts its layout depending on what content is slotted:

const template = document.createElement('template');
template.innerHTML = `
<style>
:host {
display: block;
border: 1px solid #e0e0e0;
border-radius: 8px;
overflow: hidden;
}
.header {
background: #f8f9fa;
padding: 12px 16px;
font-weight: bold;
border-bottom: 1px solid #e0e0e0;
}
.header[hidden] { display: none; }
.body { padding: 16px; }
.footer {
background: #f8f9fa;
padding: 12px 16px;
border-top: 1px solid #e0e0e0;
text-align: right;
}
.footer[hidden] { display: none; }
.badge {
display: inline-block;
background: #3498db;
color: white;
border-radius: 12px;
padding: 2px 8px;
font-size: 12px;
margin-left: 8px;
font-weight: normal;
}
</style>
<div class="header">
<slot name="header"></slot>
<span class="badge"></span>
</div>
<div class="body">
<slot></slot>
</div>
<div class="footer">
<slot name="footer"></slot>
</div>
`;

class SmartPanel extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.appendChild(template.content.cloneNode(true));

this._headerSection = shadow.querySelector('.header');
this._footerSection = shadow.querySelector('.footer');
this._badge = shadow.querySelector('.badge');

const headerSlot = shadow.querySelector('slot[name="header"]');
const footerSlot = shadow.querySelector('slot[name="footer"]');
const defaultSlot = shadow.querySelector('slot:not([name])');

// Show/hide header section based on whether content is provided
headerSlot.addEventListener('slotchange', () => {
const hasHeader = headerSlot.assignedElements().length > 0;
this._headerSection.hidden = !hasHeader;
});

// Show/hide footer section
footerSlot.addEventListener('slotchange', () => {
const hasFooter = footerSlot.assignedElements().length > 0;
this._footerSection.hidden = !hasFooter;
});

// Count body items and show badge
defaultSlot.addEventListener('slotchange', () => {
const bodyItems = defaultSlot.assignedElements();
if (bodyItems.length > 0) {
this._badge.textContent = `${bodyItems.length} item(s)`;
this._badge.hidden = false;
} else {
this._badge.hidden = true;
}
});
}
}

customElements.define('smart-panel', SmartPanel);
<!-- Full panel: header, body, and footer -->
<smart-panel>
<span slot="header">Task List</span>
<div>Complete documentation</div>
<div>Review pull requests</div>
<div>Deploy to staging</div>
<button slot="footer">Mark All Complete</button>
</smart-panel>

<!-- Minimal panel: body only, no header or footer -->
<smart-panel>
<p>Just some content without header or footer.</p>
</smart-panel>

The first panel shows the header with a "3 item(s)" badge and the footer with the button. The second panel hides both the header and footer sections automatically because no content was provided for those slots.

assignedSlot on Light DOM Elements​

You can also go in the reverse direction. Every Light DOM element that has been assigned to a slot has an assignedSlot property pointing to the <slot> element it was projected into:

<smart-panel>
<span slot="header" id="my-header">Panel Title</span>
<p id="my-content">Some content</p>
</smart-panel>

<script>
const header = document.getElementById('my-header');
const content = document.getElementById('my-content');

console.log(header.assignedSlot);
// Output: <slot name="header">

console.log(header.assignedSlot.name);
// Output: "header"

console.log(content.assignedSlot);
// Output: <slot> (the default slot)

console.log(content.assignedSlot.name);
// Output: "" (empty string for default slot)
</script>
caution

assignedSlot returns null if the element is not currently assigned to any slot, or if the shadow root is in closed mode. In closed mode, the browser hides the internal slot references to maintain encapsulation.

// With mode: 'closed'
console.log(header.assignedSlot);
// Output: null (even though the element IS slotted)

Putting It All Together: A Complete Composition Example​

Here is a practical example that uses named slots, a default slot, fallback content, slotchange events, and assignedElements() together:

const notificationTemplate = document.createElement('template');
notificationTemplate.innerHTML = `
<style>
:host {
display: block;
margin: 8px 0;
}
.notification {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 14px 16px;
border-radius: 8px;
border-left: 4px solid #3498db;
background: #ebf5fb;
font-family: system-ui, sans-serif;
}
:host([type="success"]) .notification {
border-left-color: #27ae60;
background: #eafaf1;
}
:host([type="warning"]) .notification {
border-left-color: #f39c12;
background: #fef9e7;
}
:host([type="error"]) .notification {
border-left-color: #e74c3c;
background: #fdedec;
}
.icon { font-size: 20px; flex-shrink: 0; padding-top: 2px; }
.body { flex: 1; }
.title { font-weight: bold; margin-bottom: 4px; }
.message { font-size: 14px; color: #555; line-height: 1.5; }
.actions {
display: flex;
gap: 8px;
margin-top: 10px;
}
.actions[hidden] { display: none; }
.meta { font-size: 11px; color: #999; margin-top: 6px; }
.meta[hidden] { display: none; }
</style>
<div class="notification">
<div class="icon">
<slot name="icon">â„šī¸</slot>
</div>
<div class="body">
<div class="title">
<slot name="title">Notification</slot>
</div>
<div class="message">
<slot>No message provided.</slot>
</div>
<div class="actions">
<slot name="actions"></slot>
</div>
<div class="meta">
<slot name="meta"></slot>
</div>
</div>
</div>
`;

class AppNotification extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.appendChild(notificationTemplate.content.cloneNode(true));

const actionsSection = shadow.querySelector('.actions');
const metaSection = shadow.querySelector('.meta');
const actionsSlot = shadow.querySelector('slot[name="actions"]');
const metaSlot = shadow.querySelector('slot[name="meta"]');

// Hide sections that have no slotted content
actionsSlot.addEventListener('slotchange', () => {
actionsSection.hidden = actionsSlot.assignedElements().length === 0;
});

metaSlot.addEventListener('slotchange', () => {
metaSection.hidden = metaSlot.assignedElements().length === 0;
});
}
}

customElements.define('app-notification', AppNotification);
<!-- Rich notification with all slots filled -->
<app-notification type="success">
<span slot="icon">✅</span>
<span slot="title">Deployment Complete</span>
<p>Version 2.4.1 has been deployed to production successfully.</p>
<span slot="actions">
<a href="/logs">View Logs</a>
<a href="/rollback">Rollback</a>
</span>
<span slot="meta">Deployed by Alice at 14:32 UTC</span>
</app-notification>

<!-- Simple notification with fallbacks -->
<app-notification type="warning">
<span slot="title">Disk Space Low</span>
<p>Server storage is at 92% capacity.</p>
</app-notification>

<!-- Minimal notification: default icon, default title -->
<app-notification type="error">
<p>An unexpected error occurred. Please try again later.</p>
</app-notification>

The first notification shows all sections. The second shows the warning with custom title and message, but uses the default icon (â„šī¸) and hides the actions and meta sections. The third uses all fallbacks except the message slot.

Summary​

ConceptKey Point
Named slots<slot name="x"> receives Light DOM elements with slot="x"
Default slot<slot> (no name) receives all unmatched Light DOM children
Slot assignmentOnly direct children of the host can be assigned to slots
Multiple elements per slotSeveral elements can target the same named slot
Fallback contentHTML inside <slot>...</slot> shown when no content is assigned
Fallback rulesEven an empty element replaces the fallback
slotchange eventFires when assigned nodes change (not when their content changes)
slotchange scopecomposed: false, only detectable inside the shadow tree
assignedNodes()Returns all nodes (elements, text, comments) assigned to a slot
assignedElements()Returns only element nodes assigned to a slot
{ flatten: true }Returns fallback content when no Light DOM content is assigned
assignedSlotProperty on Light DOM elements pointing to their assigned slot

Slots are the composition mechanism of Web Components. They let component authors define flexible structures while giving consumers full control over the content. Combined with slotchange and assignedElements(), they enable components that intelligently adapt their layout and behavior based on what content is provided.