← Back to DevBytes

HTML Slot Elements: Complete Guide

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:

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:

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:

<!-- 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:

// 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

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles