Flattening Nested Slot Trees with assignedElements(): Reading Through Layers of Components

Design systems compose. A <data-table> projects into a <card-shell>, which projects into a <page-section>, and by the time the browser paints, a single consumer-supplied <tr> has passed through three shadow boundaries. Each hop is a slot assigned to another slot — a legal, extremely common arrangement that the DOM Standard handles transparently for rendering and completely opaquely for scripting.

Ask the outermost slot what it holds and you get a <slot> element, not the content. Ask again and you get another <slot>. Components that need to count, index, validate, or wire keyboard navigation across projected items therefore have to resolve the chain, and the platform provides exactly one primitive for it: the flatten option on assignedNodes() and assignedElements(). This deep dive sits under Slot Composition & Content Projection in Core Architecture & Lifecycle Management.

Problem Statement: The Component That Counts Slots Instead of Items

class ItemCounter extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' }).innerHTML = `
      <slot></slot>
      <p class="count">0 items</p>`;
  }

  connectedCallback() {
    const slot = this.shadowRoot.querySelector('slot');
    const paint = () => {
      // BUG: stops at the first boundary.
      const items = slot.assignedElements();
      this.shadowRoot.querySelector('.count').textContent = `${items.length} items`;
    };
    slot.addEventListener('slotchange', paint);
    paint();
  }
}
customElements.define('item-counter', ItemCounter);

class ListShell extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' }).innerHTML = `
      <item-counter><slot></slot></item-counter>`;
  }
}
customElements.define('list-shell', ListShell);
<list-shell>
  <article>First</article>
  <article>Second</article>
  <article>Third</article>
</list-shell>

The page shows “1 items”. The counter’s slot has exactly one assigned element — the <slot> inside list-shell’s shadow tree — and that single node is standing in for three articles. Nothing is broken; the counter is simply reading the wrong level of the tree.

A slot assigned to another slot hides the real content Consumer articles are assigned to a slot inside list-shell, which is itself assigned to a slot inside item-counter, so an unflattened read returns one slot element instead of three articles. Consumer light tree <article> First <article> Second <article> Third 3 real elements list-shell shadow <item-counter> <slot> (A) </item-counter> A holds the 3 articles item-counter shadow <slot> (B) B.assignedElements() = [ slot A ] → length 1 with flatten → length 3 Why rendering still looks right The flattened tree resolves the chain for layout and paint; only scripting sees the intermediate slot.

Root-Cause Analysis

The DOM Standard permits a <slot> element to be a slottable. When a slot lives in the light tree of another component — which is precisely what <item-counter><slot></slot></item-counter> creates — it is an ordinary child of that host and is assigned like any other element. The result is a chain: consumer nodes to slot A, slot A to slot B, and so on for as many layers as the composition has.

For rendering, the standard resolves the chain when it computes the flattened tree, so every layer paints correctly and inherited properties flow through. For scripting, assignedNodes() deliberately reports the direct assignment — the one hop the slot actually performed — because that is the only answer that is well-defined without a policy about how far to walk.

The flatten option supplies that policy. With { flatten: true } the algorithm walks the chain: for every assigned node that is itself a slot, it substitutes that slot’s assigned nodes, recursively, and it substitutes fallback children for any slot in the chain that has nothing assigned. The result is the list of nodes that genuinely render at that position — the same set the flattened tree paints.

Two properties of the flattened read are worth committing to memory:

Debugging Pitfall — slotchange does not propagate up the chain. Each slot fires its own event for its own assignment. When a consumer appends a fourth article, slot A's assignment changes and A fires; slot B's assignment is unchanged — it still holds exactly one node, slot A — so B never fires and the counter never updates. A component that reads flattened content must listen on its own slot and accept that nested changes arrive only if an inner component forwards them, typically as a custom event.

That non-propagation is the deepest trap in nested composition. It is easy to conclude that flatten: true is broken when in fact the read is correct and the trigger never fired. The fix is architectural: an inner component that owns projected content should dispatch a composed event when its content changes, and outer components should listen for it — the contract pattern described in composing custom events across shadow boundaries.

Which slot fires when nested projected content changes Appending a consumer child changes the inner slot assignment and fires its slotchange, while the outer slot assignment is unchanged and stays silent, so a composed custom event is needed to bridge the gap. consumer appends a fourth <article> slot A fires 3 nodes → 4 nodes slot B stays silent still holds [ slot A ] The bridge The component owning slot A dispatches a composed CustomEvent on itself. The component owning slot B listens for that event and re-reads with flatten: true. Result: one authoritative signal per layer, no polling and no MutationObserver.

Production-Safe Pattern

The corrected pair below reads flattened content, filters out the whitespace text that formatters introduce, and forwards a composed event so outer layers can react.

/** Resolve everything actually rendering at a slot, across nested components. */
const effectiveElements = (slot) =>
  slot.assignedElements({ flatten: true })
    // A flattened read can still surface a slot when an inner root is closed.
    .filter((el) => el.localName !== 'slot');

class ItemCounter extends HTMLElement {
  #controller = null;

  constructor() {
    super();
    this.attachShadow({ mode: 'open' }).innerHTML = `
      <style>:host { display: block; } .count { font-variant-numeric: tabular-nums; }</style>
      <slot></slot>
      <p class="count" aria-live="polite">0 items</p>`;
  }

  connectedCallback() {
    this.#controller = new AbortController();
    const { signal } = this.#controller;
    const slot = this.shadowRoot.querySelector('slot');

    const paint = () => {
      const items = effectiveElements(slot);
      this.shadowRoot.querySelector('.count').textContent =
        `${items.length} ${items.length === 1 ? 'item' : 'items'}`;
    };

    slot.addEventListener('slotchange', paint, { signal });
    // Inner layers announce their own changes; slotchange will not travel up for us.
    this.addEventListener('content-change', paint, { signal });
    paint();
  }

  disconnectedCallback() {
    this.#controller?.abort();
    this.#controller = null;
  }
}
customElements.define('item-counter', ItemCounter);

class ListShell extends HTMLElement {
  #controller = null;

  constructor() {
    super();
    this.attachShadow({ mode: 'open' }).innerHTML = `
      <item-counter><slot></slot></item-counter>`;
  }

  connectedCallback() {
    this.#controller = new AbortController();
    const slot = this.shadowRoot.querySelector('slot');
    slot.addEventListener('slotchange', () => {
      // Composed so it crosses the boundary into item-counter's ancestors.
      this.dispatchEvent(new CustomEvent('content-change', {
        detail: { count: effectiveElements(slot).length },
        bubbles: true,
        composed: true
      }));
    }, { signal: this.#controller.signal });
  }

  disconnectedCallback() {
    this.#controller?.abort();
    this.#controller = null;
  }
}
customElements.define('list-shell', ListShell);

The localName !== 'slot' filter is not paranoia. If any layer in the chain is a closed root, the flattened walk cannot descend into it and returns the slot element itself. Filtering keeps a counter from reporting a phantom item, and it makes the helper safe to reuse in components that do not know how their consumers will nest them.

Verification

const shell = document.querySelector('list-shell');
const counter = shell.shadowRoot.querySelector('item-counter');
const slotB = counter.shadowRoot.querySelector('slot');

// Unflattened: one node, and it is a slot.
console.assert(slotB.assignedElements().length === 1, 'direct assignment is the inner slot');
console.assert(slotB.assignedElements()[0].localName === 'slot', 'and it is a <slot>');

// Flattened: the real articles.
console.assert(slotB.assignedElements({ flatten: true }).length === 3, 'flatten resolves 3');

// The bridge works: appending updates the counter through the custom event.
shell.append(Object.assign(document.createElement('article'), { textContent: 'Fourth' }));
await new Promise((resolve) => queueMicrotask(resolve));
console.assert(
  counter.shadowRoot.querySelector('.count').textContent === '4 items',
  'nested change must reach the outer component'
);

In DevTools the chain is visible without any script. Expand the host, expand each #shadow-root, and follow the reveal badges: each one jumps to the node the slot received, so clicking through them walks exactly the path flatten: true walks. If a badge disappears at some layer, that layer is a closed root and is where your flattened read will stop.

When to Use vs When to Avoid

Read Use flatten: true Use the default
Count or index items for keyboard navigation Yes — you need real elements No
Validate that only permitted child types were supplied Yes — checks the effective content No
Decide whether to show an empty state Yes — fallback counts as content No
Attribute a change to the consumer who caused it No Yes — direct assignment is the truth
Implement manual assignment bookkeeping No Yes — you assigned those nodes
Cheapest possible read in a hot path No — the walk is recursive Yes

Cost is the reason the default is not flattened. Each call re-walks the chain, so a component that reads flattened content inside a resize handler or an animation frame should cache the array and invalidate it from slotchange and the forwarded event, not recompute per frame. For deep design-system nesting the walk is still cheap in absolute terms — it is proportional to the number of layers times the nodes at each — but it is not free, and it is easy to place in a loop by accident.

What each read returns as composition depth grows At one, two and three layers of nesting the unflattened read always returns a single slot element while the flattened read returns the real content at a cost proportional to depth. Three consumer articles, read from the outermost component 1 layer default: 3 articles flatten: 3 articles no difference, no reason to flatten 2 layers default: 1 slot flatten: 3 articles one hop resolved 3 layers default: 1 slot flatten: 3 articles two hops resolved per call The rule that follows Flattening is free at one layer and re-walks the chain at every layer beyond it: cache the array, invalidate on events.

Frequently Asked Questions

Why does assignedElements() return a slot element?

Because a <slot> in another component’s light tree is an ordinary slottable and gets assigned like any element. The unflattened read reports the one hop that slot actually performed, which for nested composition is the intermediate slot rather than the consumer’s content.

Does slotchange fire on the outer slot when nested content changes?

No. The outer slot’s assignment is unchanged — it still holds the same inner slot — so it has nothing to report. Bridge the gap with a composed custom event dispatched by the component that owns the inner slot.

Does flatten work through a closed shadow root?

No. The walk cannot descend into a root the caller cannot access, so it stops there and returns the slot element itself. Filter those out, and treat opaque composition as one of the real costs of choosing closed mode.

Is assignedNodes({ flatten: true }) the same as querying the rendered DOM?

For that slot’s position, effectively yes — it returns the nodes the flattened tree paints there, including resolved fallbacks. It is not a whole-page query, though: it answers only for the slot you called it on.