What Are HTML Slot Elements?
The HTML <slot> element is a fundamental building block of Web Components. It acts as a placeholder inside a component's shadow DOM that allows developers to inject content from the outer document â known as "light DOM" content â into specific locations within the component's encapsulated structure. Think of a slot as a dynamic portal: content you write between the custom element's tags in your HTML gets projected into the slot's position inside the shadow tree, without the component needing to know exactly what that content will be ahead of time.
Slots enable a clean separation of concerns. The component author defines the internal structure, styling, and logic within the shadow DOM, while the component consumer provides the actual content that populates it. This pattern is the cornerstone of truly reusable, composable UI components on the web platform.
The Shadow DOM and Web Components Context
To fully understand slots, you need to grasp the relationship between three DOM concepts:
- Light DOM: The regular DOM tree that the page author writes â the HTML markup that lives directly in the main document, including the content inside custom element tags.
- Shadow DOM: A separate, encapsulated DOM subtree attached to a custom element. Styles scoped inside the shadow DOM don't leak out, and outside styles (unless explicitly targeted) don't penetrate in.
- Flattened DOM: The result of the browser merging the light DOM content into the shadow DOM via slots. This is what the user ultimately sees rendered on screen, though it exists only as a rendering concept â the actual DOM trees remain distinct.
When a browser renders a component with a shadow root that contains <slot> elements, it takes the child nodes from the light DOM and "assigns" them to matching slots. The component's shadow tree remains intact, but the rendered output shows the distributed content in the correct positions. This process is called "composition" or "distribution."
Why Slot Elements Matter
đ Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →Before native slots, developers relied on complex JavaScript to manually move DOM nodes into component templates, often breaking encapsulation or requiring fragile string-based HTML injection. Slots solve several critical problems elegantly:
- Declarative Composition: Content projection is defined entirely in HTML â no imperative DOM manipulation required. This makes components self-documenting and easier to reason about.
- Encapsulation with Flexibility: The shadow DOM shields internal structure from external CSS and JS interference, yet slots provide controlled openings for content to flow through.
- Separation of Structure and Content: Component authors focus on the "skeleton" (layout, styling, behavior) while consumers supply the "meat" (text, images, interactive elements).
- Dynamic Updates: When light DOM content changes, the slot automatically re-projects the updated content. No manual refresh is needed.
- Accessibility Preservation: Because slotted content remains in the main document's accessibility tree (it isn't "hidden" inside the shadow root), assistive technologies can traverse it naturally.
- Framework Interoperability: Slots work with any framework or vanilla HTML. React, Vue, Angular, Svelte â all can render children that get properly slotted into web components.
How to Use Slot Elements
Using slots involves two sides: the component author places <slot> elements inside the shadow DOM, and the component consumer writes content between the custom element's opening and closing tags. Let's break down every aspect of slot mechanics.
Basic Unnamed Slot (Default Slot)
The simplest form of slot is one without a name attribute. Any light DOM content that doesn't have an explicit slot attribute (or has no matching named slot) gets projected into this default slot. A shadow DOM can contain at most one unnamed slot â if you include multiple, all unnamed light DOM content goes into the first one encountered.
<!-- The component consumer writes this in their HTML -->
<my-card>
<p>This paragraph becomes the card's body content.</p>
<img src="photo.jpg" alt="A beautiful landscape">
</my-card>
<!-- Inside the component's shadow DOM -->
<style>
.card {
border: 1px solid #ccc;
border-radius: 8px;
padding: 16px;
background: white;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
</style>
<div class="card">
<slot></slot> <!-- All light DOM children land here -->
</div>
The rendered flattened DOM shows the <p> and <img> elements inside the card div, styled by the shadow DOM's CSS. The component never knew what content would arrive â it just provided a slot-shaped hole and styled the container.
Default Slot Content (Fallback)
A powerful feature of slots is the ability to provide fallback content. Whatever you place between the opening and closing <slot> tags becomes the default that displays when no light DOM content is assigned. If content IS provided, the fallback is completely replaced.
<!-- Shadow DOM with fallback content -->
<div class="notification">
<slot>
<span class="default-icon">âšī¸</span>
<span>No message provided</span>
</slot>
</div>
<!-- Usage 1: No content provided â fallback renders -->
<my-notification></my-notification>
<!-- Usage 2: Content provided â fallback replaced -->
<my-notification>
<span class="urgent">â ī¸ System alert: Disk space low</span>
</my-notification>
Fallback content is ideal for optional sections like icons, placeholder text, or empty states. Note that the fallback itself can contain complex HTML, including styled elements, other components, or even nested slots.
Named Slots with the name Attribute
For components requiring multiple distinct content zones, you use named slots. The component author gives each <slot> a unique name attribute. The consumer then uses the slot attribute on their light DOM elements to target specific slots. Elements with a slot attribute matching a slot's name are projected there; elements without a slot attribute go to the unnamed default slot.
<!-- Component consumer's HTML -->
<user-profile>
<img slot="avatar" src="user-123.jpg" alt="Profile photo">
<h2 slot="name">Jane Doe</h2>
<p slot="bio">Full-stack developer and open source enthusiast.</p>
<div>
<button>Follow</button>
<button>Message</button>
</div>
</user-profile>
<!-- Inside the shadow DOM of user-profile -->
<style>
.profile {
display: grid;
grid-template-columns: 80px 1fr;
grid-template-rows: auto auto auto;
gap: 8px 16px;
align-items: start;
}
.avatar-slot { grid-row: 1 / 3; grid-column: 1; }
.name-slot { grid-row: 1; grid-column: 2; }
.bio-slot { grid-row: 2; grid-column: 2; }
.actions-slot { grid-row: 3; grid-column: 1 / -1; }
</style>
<div class="profile">
<div class="avatar-slot">
<slot name="avatar">
<div class="default-avatar">đ¤</div>
</slot>
</div>
<div class="name-slot">
<slot name="name">Anonymous User</slot>
</div>
<div class="bio-slot">
<slot name="bio">No bio available.</slot>
</div>
<div class="actions-slot">
<slot></slot> <!-- Unnamed slot catches the buttons div -->
</div>
</div>
Notice how the <div> containing the buttons doesn't have a slot attribute â it flows into the unnamed default slot. This hybrid approach lets you designate specific portals for key elements while still having a catch-all area for everything else.
The slot Attribute on Light DOM Elements
The slot attribute is the consumer-side counterpart to the name attribute on slots. Its value must match exactly the name of the target slot. Important rules:
- Only direct children of the custom element (not nested descendants) can be assigned to slots. The
slotattribute on a grandchild element is ignored by the slotting algorithm. - Multiple elements can target the same named slot â they all get projected in document order, appended sequentially inside that slot.
- If an element's
slotattribute value doesn't match any existing named slot in the shadow DOM, that element becomes "unassigned" and won't render unless there's a default slot to catch it. - The
slotattribute is evaluated at the time of slot assignment; dynamically changing it (via JavaScript) triggers re-projection.
<!-- Multiple elements targeting the same slot -->
<my-list>
<li slot="items">Apples</li>
<li slot="items">Bananas</li>
<li slot="items">Cherries</li>
</my-list>
<!-- Shadow DOM: all three <li> elements render inside this slot -->
<ul>
<slot name="items"></slot>
</ul>
Nested Slots and Slot Chains
Slots can be nested when one custom element contains another, creating "slot chains" where content passes through multiple layers of composition. This is particularly powerful for building complex component hierarchies.
<!-- Outer component: my-page -->
<my-page>
<my-section slot="sidebar">
<h3 slot="title">Navigation</h3>
<nav slot="content">
<a href="/">Home</a>
<a href="/about">About</a>
</nav>
</my-section>
</my-page>
<!-- my-page shadow DOM -->
<div class="layout">
<aside><slot name="sidebar"></slot></aside>
<main><slot name="main"></slot></main>
</div>
<!-- my-section shadow DOM -->
<div class="section">
<header><slot name="title"></slot></header>
<div class="body"><slot name="content"></slot></div>
</div>
Here, <my-section> itself uses slots internally, and it's slotted into <my-page>'s sidebar slot. The content flows: light DOM â my-section's shadow slots â my-page's sidebar slot â final rendered output. Each component manages its own encapsulation while participating in a larger composition.
Accessing Slotted Content via JavaScript
Slots aren't just a rendering feature â they have a rich JavaScript API for inspecting and reacting to distributed content. Key properties and events:
HTMLSlotElement.assignedNodes(): Returns the nodes currently assigned to this slot. Pass{ flatten: true }to include nodes distributed through nested slots.HTMLSlotElement.assignedElements(): LikeassignedNodes()but returns only Element nodes (no text nodes).slotchangeevent: Fires on a<slot>element whenever its assigned nodes change â when content is added, removed, or re-assigned.Element.assignedSlot: On any slotted element, this property references the<slot>element it's currently assigned to (or null if unassigned).
// Inside the component's connectedCallback or constructor
class MyTabs extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<div class="tabs">
<slot name="tab-headers"></slot>
<slot name="tab-panels"></slot>
</div>
`;
}
connectedCallback() {
const headerSlot = this.shadowRoot.querySelector('slot[name="tab-headers"]');
// Listen for changes to the slotted content
headerSlot.addEventListener('slotchange', (event) => {
const assignedElements = headerSlot.assignedElements();
console.log(`Tab headers updated: ${assignedElements.length} headers`);
// Dynamically set up click handlers on slotted tab headers
assignedElements.forEach((header, index) => {
header.addEventListener('click', () => this.selectTab(index));
});
});
// Initial setup for any already-present content
const initialHeaders = headerSlot.assignedElements();
if (initialHeaders.length > 0) {
initialHeaders[0].classList.add('active');
}
}
selectTab(index) {
const panels = this.shadowRoot.querySelector('slot[name="tab-panels"]').assignedElements();
panels.forEach((panel, i) => {
panel.style.display = i === index ? 'block' : 'none';
});
const headers = this.shadowRoot.querySelector('slot[name="tab-headers"]').assignedElements();
headers.forEach((h, i) => h.classList.toggle('active', i === index));
}
}
customElements.define('my-tabs', MyTabs);
The slotchange event is crucial for components that need to wire up interactivity on slotted elements. It fires once during initial slot assignment (even before connectedCallback in some cases) and again whenever the set of assigned nodes changes. Note that it does NOT fire for changes within the slotted elements themselves (like attribute changes) â only for node assignment changes.
Complete Practical Examples
Example 1: A Custom Card Component with Rich Slotting
This component demonstrates multiple named slots, fallback content, and how styling applies to slotted content from within the shadow DOM.
<!-- HTML usage by the consumer -->
<custom-card>
<img slot="card-image" src="mountains.jpg" alt="Snow-capped peaks at sunset">
<h3 slot="card-title">Weekend Getaway</h3>
<p slot="card-body">
Experience breathtaking views and cozy cabins just two hours from the city.
Perfect for a quick escape from the daily grind.
</p>
<div slot="card-actions">
<button class="primary">Book Now</button>
<button class="secondary">Learn More</button>
</div>
</custom-card>
<!-- JavaScript: CustomCard component definition -->
<script>
class CustomCard extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
max-width: 360px;
font-family: system-ui, sans-serif;
}
.card {
background: #ffffff;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
transition: box-shadow 0.3s ease;
}
.card:hover {
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
}
.image-wrapper {
width: 100%;
height: 200px;
overflow: hidden;
}
.image-wrapper ::slotted(img) {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.content {
padding: 20px;
}
.title-slot {
margin: 0 0 12px 0;
font-size: 1.25rem;
color: #1a1a1a;
}
.title-slot ::slotted(h3) {
margin: 0;
font-weight: 600;
}
.body-slot {
color: #555;
line-height: 1.6;
margin-bottom: 20px;
}
.body-slot ::slotted(p) {
margin: 0;
}
.actions {
display: flex;
gap: 10px;
padding-top: 16px;
border-top: 1px solid #eee;
}
.actions ::slotted(button) {
padding: 8px 20px;
border-radius: 6px;
border: none;
cursor: pointer;
font-size: 0.9rem;
font-weight: 500;
transition: background 0.2s;
}
.actions ::slotted(.primary) {
background: #0066cc;
color: white;
}
.actions ::slotted(.primary:hover) {
background: #0052a3;
}
.actions ::slotted(.secondary) {
background: #f0f0f0;
color: #333;
}
.actions ::slotted(.secondary:hover) {
background: #e0e0e0;
}
.fallback-image {
width: 100%;
height: 200px;
background: linear-gradient(135deg, #667eea, #764ba2);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 3rem;
}
</style>
<div class="card">
<div class="image-wrapper">
<slot name="card-image">
<div class="fallback-image">đŧī¸</div>
</slot>
</div>
<div class="content">
<div class="title-slot">
<slot name="card-title">Card Title</slot>
</div>
<div class="body-slot">
<slot name="card-body">Card description goes here.</slot>
</div>
<div class="actions">
<slot name="card-actions"></slot>
</div>
</div>
</div>
`;
}
}
customElements.define('custom-card', CustomCard);
</script>
Key observations: The ::slotted() CSS pseudo-element allows the shadow DOM to style the slotted elements themselves (not just their containers). However, ::slotted() can only target top-level slotted elements â not their nested children. Also note the fallback gradient image that appears when no card-image slot content is provided.
Example 2: A Tab Container Component
This example showcases a component where the consumer provides both tab headers and tab panels, and the component wires up the switching logic entirely internally using slotchange and assignedElements().
<!-- Consumer usage -->
<tab-container>
<button slot="tab-trigger">Overview</button>
<div slot="tab-panel">
<h3>Project Overview</h3>
<p>This is the main dashboard showing key metrics and recent activity.</p>
</div>
<button slot="tab-trigger">Details</button>
<div slot="tab-panel">
<h3>Detailed Analysis</h3>
<p>Deep dive into performance data, trends, and comparative benchmarks.</p>
</div>
<button slot="tab-trigger">Settings</button>
<div slot="tab-panel">
<h3>Configuration</h3>
<p>Adjust preferences, notification settings, and account parameters.</p>
</div>
</tab-container>
<!-- Component JavaScript -->
<script>
class TabContainer extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this._activeIndex = 0;
this.shadowRoot.innerHTML = `
<style>
:host { display: block; font-family: system-ui; }
.tab-header {
display: flex;
border-bottom: 2px solid #e0e0e0;
margin-bottom: 16px;
}
.tab-header ::slotted(button) {
padding: 10px 24px;
border: none;
background: none;
cursor: pointer;
font-size: 1rem;
color: #666;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
transition: all 0.2s;
position: relative;
}
.tab-header ::slotted(button.active) {
color: #0066cc;
border-bottom-color: #0066cc;
font-weight: 600;
}
.tab-header ::slotted(button:hover) {
color: #333;
}
.tab-panel-area ::slotted(div) {
display: none;
line-height: 1.6;
color: #333;
}
.tab-panel-area ::slotted(div.active) {
display: block;
}
</style>
<div class="tab-header">
<slot name="tab-trigger"></slot>
</div>
<div class="tab-panel-area">
<slot name="tab-panel"></slot>
</div>
`;
this._triggerSlot = this.shadowRoot.querySelector('slot[name="tab-trigger"]');
this._panelSlot = this.shadowRoot.querySelector('slot[name="tab-panel"]');
}
connectedCallback() {
this._triggerSlot.addEventListener('slotchange', () => this._setupTabs());
this._panelSlot.addEventListener('slotchange', () => this._setupTabs());
// Run setup immediately in case content is already present
this._setupTabs();
}
_setupTabs() {
const triggers = this._triggerSlot.assignedElements();
const panels = this._panelSlot.assignedElements();
// Pair triggers with panels by index
triggers.forEach((trigger, i) => {
trigger.addEventListener('click', () => this._activateTab(i));
// Remove any lingering class
trigger.classList.remove('active');
});
// Ensure we have a valid active index
if (this._activeIndex >= triggers.length) {
this._activeIndex = 0;
}
this._activateTab(this._activeIndex);
}
_activateTab(index) {
this._activeIndex = index;
const triggers = this._triggerSlot.assignedElements();
const panels = this._panelSlot.assignedElements();
triggers.forEach((t, i) => t.classList.toggle('active', i === index));
panels.forEach((p, i) => p.classList.toggle('active', i === index));
}
}
customElements.define('tab-container', TabContainer);
</script>
This pattern is powerful because the consumer simply provides semantic HTML (buttons and divs), and the component automatically transforms them into a functional tab interface. The consumer doesn't need to write any JavaScript for tab switching â the component handles everything internally, yet the actual content remains fully customizable.
Example 3: A Modal/Dialog Component with Slot-Based Layout
This component demonstrates how slots enable a complex layout with header, body, and footer sections, plus a default slot for flexible content insertion.
<!-- Consumer usage -->
<custom-modal>
<span slot="modal-title">Confirm Deletion</span>
<p>Are you sure you want to permanently delete this item? This action cannot be undone.</p>
<div slot="modal-footer">
<button class="cancel-btn">Cancel</button>
<button class="confirm-btn danger">Delete Forever</button>
</div>
</custom-modal>
<!-- Component JavaScript -->
<script>
class CustomModal extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this._visible = false;
this.shadowRoot.innerHTML = `
<style>
:host { display: none; }
:host([open]) { display: block; }
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.modal {
background: white;
border-radius: 12px;
width: 90%;
max-width: 480px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
animation: slideUp 0.25s ease;
}
@keyframes slideUp {
from { transform: translateY(30px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.modal-header {
padding: 20px 24px;
border-bottom: 1px solid #eee;
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-header ::slotted(*) {
font-size: 1.2rem;
font-weight: 600;
margin: 0;
}
.close-button {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #999;
padding: 4px 8px;
border-radius: 4px;
}
.close-button:hover {
background: #f5f5f5;
color: #333;
}
.modal-body {
padding: 24px;
line-height: 1.6;
color: #444;
}
.modal-body ::slotted(p) {
margin: 0;
}
.modal-footer {
padding: 16px 24px;
border-top: 1px solid #eee;
display: flex;
justify-content: flex-end;
gap: 10px;
}
.modal-footer ::slotted(button) {
padding: 10px 20px;
border-radius: 6px;
border: 1px solid #ccc;
cursor: pointer;
font-weight: 500;
font-size: 0.95rem;
}
.modal-footer ::slotted(.cancel-btn) {
background: #f5f5f5;
border-color: #ddd;
}
.modal-footer ::slotted(.cancel-btn:hover) {
background: #e8e8e8;
}
.modal-footer ::slotted(.confirm-btn) {
background: #0066cc;
color: white;
border-color: #0066cc;
}
.modal-footer ::slotted(.confirm-btn:hover) {
background: #0052a3;
}
.modal-footer ::slotted(.danger) {
background: #cc0000;
border-color: #cc0000;
}
.modal-footer ::slotted(.danger:hover) {
background: #a30000;
}
</style>
<div class="overlay">
<div class="modal" role="dialog" aria-modal="true">
<div class="modal-header">
<slot name="modal-title">Dialog</slot>
<button class="close-button" aria-label="Close">Ã</button>
</div>
<div class="modal-body">
<slot></slot> <!-- Unnamed slot catches the <p> -->
</div>
<div class="modal-footer">
<slot name="modal-footer"></slot>
</div>
</div>
</div>
`;
// Wire up close button
this.shadowRoot.querySelector('.close-button').addEventListener('click', () => {
this.removeAttribute('open');
});
// Close on overlay click
this.shadowRoot.querySelector('.overlay').addEventListener('click', (e) => {
if (e.target === e.currentTarget) this.removeAttribute('open');
});
}
// Public methods for showing/hiding
show() { this.setAttribute('open', ''); }
hide() { this.removeAttribute('open'); }
// Observe the 'open' attribute
static get observedAttributes() { return ['open']; }
attributeChangedCallback(name, oldVal, newVal) {
if (name === 'open') {
this._visible = newVal !== null;
}
}
}
customElements.define('custom-modal', CustomModal);
</script>
This modal component shows how slots enable a clean API: the consumer provides a title via a named slot, body content via the default slot, and action buttons via another named slot. The component handles all the overlay logic, animations, accessibility attributes, and close behavior. The consumer's content is projected into a well-structured, accessible dialog without them needing to think about the mechanics.
Best Practices for Using Slots
- Provide Meaningful Fallbacks: Every slot should have sensible default content. This makes your component robust when consumers omit optional sections. A card without an image slot should show a placeholder, not collapse awkwardly.
- Use Descriptive Slot Names: Names like
header,footer,title,actionsclearly communicate intent. Avoid cryptic names likeslot-1orzone-a. The slot names serve as the component's public API. - Style Slotted Content with
::slotted()Sparingly: The::slotted()pseudo-element can only style the direct slotted node, not its children. For deep styling, consider CSS custom properties (var()) that penetrate the shadow boundary, or document the expected DOM structure so consumers can style appropriately.
<