Slot Composition & Content Projection

A slot is the only sanctioned hole in an otherwise sealed shadow tree. Everything else about Shadow DOM Construction & Modes is about keeping consumer markup out; slots are the mechanism that lets specific, consumer-owned nodes back in without surrendering encapsulation. The DOM Standard calls the result the flattened tree: a composed view in which every <slot> element is replaced by its assigned nodes for the purposes of layout, painting, and event propagation, while the light-DOM parent/child relationships stay exactly where the consumer wrote them.

That split — one tree for ownership, another for rendering — is what makes composition subtle. A slotted <span> is still a child of the host element in document, still styled by the page’s global stylesheet, still reachable by document.querySelector, yet it paints inside the component’s shadow tree and inherits its layout context. Get the model right and content projection is the cleanest extension point a design system has. Get it wrong and you ship components whose contents vanish, whose slotchange handlers fire twice, or whose CSS silently refuses to apply.

This is the parent topic for slot mechanics inside Core Architecture & Lifecycle Management. It covers how the engine assembles the flattened tree, the full slot API surface, the four failure modes that account for most projection bugs, and how the model changes under React, Vue, Angular, and server rendering.

Light tree, shadow tree, and the flattened tree the engine paints Consumer children stay in the light tree while slot elements in the shadow tree pull them into a composed flattened tree used for layout and painting. Light tree (consumer owns) <media-card> <h3 slot="title"> <img slot="media"> <p> (no slot attr) <a slot="action"> </media-card> Shadow tree (component owns) #shadow-root <slot name="media"> <slot name="title"> <slot> (default) <slot name="action"> Flattened tree (painted) img (from light DOM) h3 (from light DOM) p (from light DOM) a (from light DOM) order = shadow tree order assign flatten What stays with the light tree parentNode, document.querySelector, global stylesheet rules, MutationObserver on the host What follows the flattened tree: layout, painting, inherited properties, event path
Spec authority. Slot behaviour is normative in the WHATWG DOM Standard: §4.2.2 defines slots and slottables, §4.2.2.3 defines the find a slot and assign slottables algorithms, and §4.2.2.4 defines the flattened tree used by CSS. The rendering consequences are specified in CSS Scoping Module Level 1, which defines ::slotted() and the tree-of-trees cascade order. The HTML Standard supplies HTMLSlotElement and the slotAssignment option on attachShadow. Where this page says "the engine must", that requirement is one of those clauses, not a vendor convention.

How the Engine Assembles the Flattened Tree

Slot assignment is not a one-time operation performed at attachShadow time. It is a standing relationship the engine recomputes whenever either side of the composition changes. The DOM Standard defines two algorithms that matter operationally: find a slot (given a node, which slot claims it) and assign slottables for a tree (given a shadow root, recompute every slot’s assigned nodes).

Under the default named assignment mode, find a slot runs a cheap lookup: take the node’s slot attribute — the empty string for text nodes and for elements without the attribute — and return the first <slot> in the shadow tree whose name attribute matches. Only element and text node children of the host are eligible; comments and attributes are never slottables, and grandchildren are never considered. The engine re-runs assignment when a host child is added or removed, when a host child’s slot attribute changes, when a <slot> element is added, removed, or renamed, and when a shadow root is attached to a host that already had children.

Three consequences fall directly out of that algorithm and explain most projection surprises:

The last point is worth internalising because it is the single most reported “my content disappeared” bug in component libraries. Nothing is broken from the platform’s point of view; the node is exactly where the author put it, it just has no rendering position.

Timing relative to the lifecycle

Assignment is synchronous with the DOM mutation that triggers it, but the slotchange event that reports it is not. slotchange is queued as a microtask-timed signal: the standard fires it at the end of the current microtask checkpoint, coalescing multiple mutations in the same task into a single event per affected slot. That coalescing is a feature — appending twenty children in a loop yields one slotchange, not twenty — but it means a handler cannot assume it sees each individual mutation, only the resulting state.

A shadow root attached in the constructor sees an empty host, because element construction runs before the parser has appended children. A shadow root attached in connectedCallback for a parser-created element may see some, all, or none of its children depending on how far the parser has progressed, which is exactly why the Lifecycle Callbacks Deep Dive recommends never reading child content synchronously during connection. The reliable read point is the first slotchange, covered in detail in detecting slot changes with slotchange.

When slot assignment runs relative to construction, connection, and slotchange A timeline showing the constructor with no children, connectedCallback with partial children during parsing, synchronous assignment, and the coalesced slotchange event at the microtask checkpoint. constructor connectedCallback child appended microtask checkpoint host has 0 children slots are empty children may be partially parsed assignment runs synchronously one coalesced slotchange per slot Safe read point Read assignedElements() inside the first slotchange, never synchronously in connectedCallback.

The Slot API Surface

Everything a component needs to inspect or control composition lives on three interfaces. The table below is the complete practical surface; there is no fourth place to look.

Member Interface Returns / accepts Notes
slot.name HTMLSlotElement string Empty string means the default slot.
slot.assignedNodes(options) HTMLSlotElement Node[] Includes text nodes, which is usually noise.
slot.assignedElements(options) HTMLSlotElement Element[] The one you almost always want.
options.flatten both of the above boolean When true, a slot assigned to another slot is resolved recursively, and fallback content is returned when nothing is assigned.
slot.assign(...nodes) HTMLSlotElement void Manual assignment only; throws nothing but is ignored in named mode.
node.assignedSlot Element, Text HTMLSlotElement | null The reverse lookup. null when unassigned or when the slot is in a closed root the caller cannot see.
shadowRoot.slotAssignment ShadowRoot 'named' | 'manual' Fixed at attachShadow time; read-only afterwards.
slotchange event HTMLSlotElement Event Does not bubble past the shadow root; composed is false.

Two members deserve emphasis. flatten: true changes the semantics of an empty slot from “return nothing” to “return the fallback content”, which is what you want when a component must react to effective content rather than assigned content. And assignedSlot returning null for a closed root is a deliberate information-hiding rule that mirrors the tradeoffs discussed in open vs closed shadow DOM.

Production Implementation Pattern

The component below is a complete, runnable media card. It projects four regions, hides its media wrapper when no media is supplied, forwards a semantic heading level, and cleans up after itself. Note that every read of projected content happens in a slotchange handler.

class MediaCard extends HTMLElement {
  #controller = new AbortController();

  constructor() {
    super();
    const root = this.attachShadow({ mode: 'open' });
    root.innerHTML = `
      <style>
        :host { display: block; container-type: inline-size; }
        :host([hidden]) { display: none; }
        .media { display: block; }
        /* Collapse the media region when nothing is projected into it. */
        .media[data-empty] { display: none; }
        ::slotted(img) { width: 100%; height: auto; display: block; }
        ::slotted(h3) { margin: 0 0 0.25rem; font-size: 1.05rem; }
        .actions { display: flex; gap: 0.5rem; margin-top: 0.75rem; }
      </style>
      <div class="media" data-empty><slot name="media"></slot></div>
      <slot name="title"></slot>
      <slot><em>No description provided.</em></slot>
      <div class="actions"><slot name="action"></slot></div>`;
  }

  connectedCallback() {
    const { signal } = this.#controller;
    const mediaSlot = this.shadowRoot.querySelector('slot[name="media"]');
    const wrapper = this.shadowRoot.querySelector('.media');

    // slotchange fires once on first composition and again on every later change.
    mediaSlot.addEventListener('slotchange', () => {
      const projected = mediaSlot.assignedElements();
      wrapper.toggleAttribute('data-empty', projected.length === 0);
      this.dispatchEvent(new CustomEvent('media-change', {
        detail: { count: projected.length },
        bubbles: true,
        composed: true
      }));
    }, { signal });
  }

  disconnectedCallback() {
    // One abort removes every listener registered with this signal.
    this.#controller.abort();
    this.#controller = new AbortController();
  }
}
customElements.define('media-card', MediaCard);
<media-card>
  <img slot="media" src="/cover.avif" alt="Album cover">
  <h3 slot="title">Framework-agnostic composition</h3>
  <p>Slots are the supported extension point for consumer content.</p>
  <a slot="action" href="/read">Read more</a>
</media-card>

The AbortController teardown is not optional ceremony. A component that is moved in the DOM runs disconnectedCallback and connectedCallback in sequence, so a listener registered on every connection without a matching removal accumulates one duplicate per move — the leak vector catalogued in Observer Teardown & Memory Safety.

Debugging Pitfall — the empty-slot check that always reports empty. Reading assignedElements() at the end of connectedCallback returns an empty array for parser-created elements whose children have not been parsed yet, so the data-empty attribute sticks and the media region never appears. The state must be derived inside slotchange, which fires once for the initial composition precisely so that this read has a correct moment to happen.

Common Failure Modes & Debugging Steps

1. Content renders in the document but never paints. The node’s slot attribute names a slot that does not exist, or the node is a grandchild of the host rather than a child. Diagnose with node.assignedSlot — a null result on an element you expected to project confirms it. Fix by moving the node to be a direct child of the host, or by correcting the name. Wrapping markup belongs inside the shadow tree, not around the projected node.

2. slotchange fires when nothing visibly changed. Whitespace text nodes are slottables. Reformatting a template so that the host’s children sit on their own lines introduces text nodes into the default slot and triggers assignment. Use assignedElements() rather than assignedNodes(), or filter with assignedNodes().filter(n => n.nodeType !== Node.TEXT_NODE || n.textContent.trim()).

3. Styles from the component do not reach projected nodes. ::slotted() matches only the top-level assigned node, never its descendants, and it loses to any light-DOM rule of equal specificity because the consumer’s stylesheet wins on projected content. This is a spec rule, not a bug; the workaround is to expose CSS custom properties or ::part() hooks instead, as described in Part & Slotted Selectors.

4. Fallback content shows even though children exist. Fallback renders when a slot has zero assigned nodes. If a wrapper element sits between the host and the intended child, assignment finds nothing and the fallback wins. The same symptom appears when a framework renders the children into a comment-anchored placeholder before hydration completes.

The four projection failure modes and the check that identifies each Four panels pairing a visible symptom with the diagnostic call that confirms it: assignedSlot for unmatched nodes, assignedElements for whitespace, slotted specificity for styling, and node count for unexpected fallback. Symptom → the one call that confirms the cause 1. Nothing paints node is in the DOM but never renders node.assignedSlot null → wrong name, or not a direct child fix: move or rename 2. Spurious events slotchange fires with no visible change assignedNodes() text nodes present from formatting fix: assignedElements() 3. Styles ignored component CSS does not reach the content ::slotted() reach top level only, consumer rules win fix: parts or tokens 4. Fallback shows default renders even though content exists length === 0 a wrapper sits between host and node fix: unwrap

Framework Interop

React’s createElement writes props, not attributes, for unknown names — but slot is a known HTML attribute in React 19 and is forwarded as-is, so <h3 slot="title"> composes correctly. In React 18 and earlier the attribute is also passed through, but children reordering during reconciliation can retrigger assignment more often than necessary. See bridging custom events to React for the matching event-side adapter.

Vue’s template compiler owns the word “slot” for its own component model, so the native attribute must be written explicitly: <h3 slot="title"> inside a <media-card> works because Vue treats unknown elements as native, but a Vue <template #title> does not map to a native slot at all. Mapping named slots in Vue covers the translation layer.

Angular projects with <ng-content select="[slot=title]"> in its own components, and passes native slot attributes straight through when CUSTOM_ELEMENTS_SCHEMA is registered — the pattern in projecting Angular content into web components.

For server rendering, the light-DOM children are ordinary markup and need no special treatment; only the shadow tree needs Declarative Shadow DOM. That asymmetry is what makes slotted content the most SSR-friendly part of a component: it is in the initial HTML, indexable and styleable, before any script runs.

Performance & Memory Implications

Assignment is linear in the number of host children per recomputation, and the engine recomputes on every relevant mutation. For a list component that appends a thousand rows one at a time, that is a thousand assignment passes. Batch the insert through a DocumentFragment and the engine performs one pass and fires one slotchange.

Slot elements themselves are cheap — they generate no box unless styled — but each slotchange listener is a retained reference from the shadow tree to the component instance. Registering listeners with an AbortSignal and aborting on disconnect keeps a detached component collectable.

The measurable win of flatten: true is avoiding a manual recursive walk in components that nest each other; the measurable cost is that it resolves the entire chain on each call, so cache the result rather than calling it inside a render loop.

There is a second, less obvious cost worth budgeting for in list-heavy interfaces. Because projected nodes live in the light tree but lay out inside the shadow tree, every assignment change invalidates layout for the host’s subtree in the flattened tree, not merely for the slot. A component that toggles a slot’s presence — for example by swapping between a compact and expanded template — forces the engine to re-run assignment, re-resolve inherited style on every projected node, and re-layout the whole card. Toggling a class on a stable slot instead keeps assignment untouched and reduces the work to a style recalculation, the distinction measured in optimizing style recalculation in large component trees.

Memory-wise, the failure mode to watch for is the reverse reference. Caching assignedElements() results on the component instance retains light-DOM nodes even after the consumer removes them, because the array holds strong references. If a component memoises projected content, clear the cache in the same slotchange handler that repopulates it, and never hold projected nodes in a module-level map keyed by component id — that map outlives every instance.

Browser Compatibility & Fallback Strategy

Feature Chromium Firefox Safari
<slot>, named assignment, slotchange 53 63 10.1
assignedElements() 66 66 12.1
flatten option 66 66 12.1
Manual assignment (slotAssignment: 'manual', slot.assign()) 86 92 16.4

Named assignment is universally available and needs no fallback. Manual assignment is the only member with a meaningful support floor; feature-detect with 'assign' in HTMLSlotElement.prototype and fall back to writing slot attributes, as shown in imperative slot assignment with assign().

The degradation story for slots is unusually kind. On an engine with no shadow DOM at all — the scenario that still matters for embedded webviews and locked-down enterprise browsers — the host element renders as an unknown inline element and its children render in document order, unstyled but present and readable. That is why a component whose projected content carries the real information, rather than being reconstructed from attributes, stays useful when scripting fails entirely. The pattern is developed further in graceful degradation without JavaScript.

Test coverage should assert the composition, not the markup. A contract test that queries slot.assignedElements() and compares tag names survives a template refactor; one that snapshots the shadow tree’s innerHTML breaks the moment a wrapper element is added. The distinction is the subject of contract testing custom event payloads, and it applies equally to projection contracts.

Frequently Asked Questions

Does a slotted element belong to the light DOM or the shadow DOM?

It belongs to the light DOM and always has. parentNode still points at the host, document.querySelector still finds it, and the page’s global stylesheet still styles it. Only its rendering position moves into the shadow tree, along with the layout context and inherited properties it picks up there.

Why does my component's CSS not style the content I slotted into it?

Projected nodes are styled by the document that owns them, which is the consumer’s document. ::slotted() gives the component limited reach — it matches only the top-level assigned node and loses ties to light-DOM rules. Expose custom properties or ::part() when you need deeper styling control.

Can a slot receive an element that is not a direct child of the host?

Not under named assignment. The find-a-slot algorithm only considers the host’s own children, so a grandchild has no path to a slot. Manual assignment lifts that restriction: slot.assign(node) accepts any node whose parent is the host, and the shadow root must have been created with slotAssignment: 'manual'.

How many times does slotchange fire on initial render?

Once per slot that ends up with assigned nodes, at the first microtask checkpoint after composition. Slots that stay empty do not fire at all, which is why an empty-state handler must also run once outside the event.