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.
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:
- It resolves fallbacks as well as chains. A layer whose slot is empty contributes its own children, which is why
flatten: trueis also the right call for the single-component case discussed in providing fallback content for empty slots. - It does not cross closed boundaries it cannot see. If an intermediate component used
mode: 'closed', its shadow root is unreachable, so the chain stops there. The read is still correct for the level you are on, but the composition is opaque above it — one of the concrete costs weighed in open vs closed shadow DOM tradeoffs.
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.
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.
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.
Related
- Slot Composition & Content Projection — the parent topic on assignment and the flattened tree.
- Core Architecture & Lifecycle Management — the section this deep dive belongs to.
- Detecting Slot Changes with slotchange — the event that triggers a re-read.
- Providing Fallback Content for Empty Slots — the other behaviour
flattenresolves. - Composing Custom Events Across Shadow Boundaries — how the bridging event crosses the boundary.