Imperative Slot Assignment with assign(): Projecting Without slot Attributes
Named slot assignment couples projection to markup: a node reaches a region because a consumer wrote slot="footer" on it. That coupling is usually a feature — it is declarative, it survives serialization, and it needs no script. It becomes a liability the moment the component, not the consumer, must decide where a node goes: a tab strip that projects only the active panel, a virtualised list that swaps which rows are visible, a carousel that shows three of forty slides. Writing and rewriting slot attributes on consumer-owned nodes to express that is a layering violation, and it mutates markup the consumer believes it controls.
Manual slot assignment solves this. A shadow root created with slotAssignment: 'manual' ignores slot attributes entirely; the component calls slot.assign(...nodes) and owns the mapping outright. This deep dive sits under Slot Composition & Content Projection within Core Architecture & Lifecycle Management.
Problem Statement: The Attribute-Rewriting Tab Strip
Here is a tab component that projects one panel at a time using named assignment. It works, and it is wrong in a way that only shows up in someone else’s code.
class TabStrip extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' }).innerHTML = `
<div role="tablist"><slot name="tab"></slot></div>
<slot name="active-panel"></slot>`;
}
show(index) {
const panels = [...this.querySelectorAll('[data-panel]')];
// BUG: the component rewrites attributes on nodes the consumer owns.
panels.forEach((panel, i) => {
if (i === index) panel.setAttribute('slot', 'active-panel');
else panel.removeAttribute('slot');
});
}
}
customElements.define('tab-strip', TabStrip);
<tab-strip>
<button slot="tab">Overview</button>
<button slot="tab">Details</button>
<section data-panel>Overview content</section>
<section data-panel>Detail content</section>
</tab-strip>
Three things break. A consumer framework that re-renders the panels restores its own attributes and the tab strip silently loses its selection. A MutationObserver the consumer registered on those sections now fires on every tab click for a change it did not make. And serializing the page — for a snapshot test, a print view, or server-side rendering — captures whichever tab happened to be open as if it were authored markup.
Root-Cause Analysis
The DOM Standard defines two assignment modes on a shadow root, fixed at creation by the slotAssignment option of attachShadow and exposed read-only as shadowRoot.slotAssignment.
In 'named' mode — the default — the find a slot algorithm runs on every mutation and derives the mapping from the node’s slot attribute. The component has no say; the attribute is the whole interface.
In 'manual' mode the algorithm is short-circuited. slot attributes are ignored completely, no node is assigned by default, and the mapping is exactly what the component last passed to HTMLSlotElement.assign(). The spec calls the stored list the slot’s manually assigned nodes. Four rules govern it:
- Assignment is total, not additive.
assign(a, b)replaces the previous list. Callingassign(c)afterwards leaves onlyc. To add, pass the full new list. - Only children of the host are eligible. Passing a node whose parent is not the host stores it, but it is skipped when the tree is composed — no error, no paint.
- A node may be assigned to at most one slot. Assigning it to a second slot removes it from the first, and the first slot’s
slotchangefires as well. - Assignment survives reordering. Because the mapping is by node reference, moving a child within the host does not change which slot it feeds — it only changes the order the flattened tree paints.
That last property is the one that makes manual mode worth the trouble. Under named assignment, projection order inside a slot follows light-DOM order; under manual assignment it follows the order of the arguments to assign(). A component can therefore render its consumer’s children in a sorted, filtered, or virtualised order without touching the consumer’s DOM.
slotAssignment: 'manual' to an existing component makes every previously working slot="…" attribute inert at once, so the component renders completely empty and no error appears anywhere. There is no partial mode: opting in means every projection must now be expressed as an assign() call, including default-slot content that never had an attribute in the first place.
slotchange behaves identically in both modes: it fires at the microtask checkpoint after assignment settles, and the coalescing described in detecting slot changes with slotchange applies unchanged. What differs is the trigger — in manual mode, appending a child fires nothing until the component decides to assign it.
Production-Safe Pattern
The rewritten tab strip below never touches a consumer attribute. It reads its children once per composition, keeps the mapping in the component, and degrades to named assignment where assign() is unavailable.
const SUPPORTS_MANUAL = 'assign' in HTMLSlotElement.prototype;
class TabStrip extends HTMLElement {
#index = 0;
#controller = null;
constructor() {
super();
const root = this.attachShadow({
mode: 'open',
// Falls back to the declarative mode on engines without manual assignment.
slotAssignment: SUPPORTS_MANUAL ? 'manual' : 'named'
});
root.innerHTML = `
<style>
:host { display: block; }
[role="tablist"] { display: flex; gap: 0.25rem; }
</style>
<div role="tablist"><slot name="tab"></slot></div>
<slot name="panel"></slot>`;
}
connectedCallback() {
this.#controller = new AbortController();
this.addEventListener('click', (event) => {
const tab = event.target.closest('[role="tab"]');
if (tab) this.show(this.#tabs().indexOf(tab));
}, { signal: this.#controller.signal });
this.#compose();
}
disconnectedCallback() {
this.#controller?.abort();
this.#controller = null;
}
#tabs() { return [...this.children].filter((el) => el.matches('[role="tab"]')); }
#panels() { return [...this.children].filter((el) => el.matches('[data-panel]')); }
show(index) {
if (index < 0 || index >= this.#panels().length) return;
this.#index = index;
this.#compose();
this.dispatchEvent(new CustomEvent('tab-change', {
detail: { index }, bubbles: true, composed: true
}));
}
#compose() {
const tabSlot = this.shadowRoot.querySelector('slot[name="tab"]');
const panelSlot = this.shadowRoot.querySelector('slot[name="panel"]');
const tabs = this.#tabs();
const panels = this.#panels();
tabs.forEach((tab, i) => tab.setAttribute('aria-selected', String(i === this.#index)));
if (SUPPORTS_MANUAL) {
// Assignment is total: pass the complete list every time.
tabSlot.assign(...tabs);
panelSlot.assign(panels[this.#index] ?? null);
} else {
// Declarative fallback: the attribute route, with its known tradeoffs.
tabs.forEach((tab) => { tab.slot = 'tab'; });
panels.forEach((panel, i) => {
if (i === this.#index) panel.slot = 'panel';
else panel.removeAttribute('slot');
});
}
}
}
customElements.define('tab-strip', TabStrip);
<tab-strip>
<button role="tab">Overview</button>
<button role="tab">Details</button>
<section data-panel>Overview content</section>
<section data-panel>Detail content</section>
</tab-strip>
Note what the consumer markup no longer contains: not a single slot attribute. The component derives roles from role="tab" and data-panel, which are semantic markers the consumer would write anyway. That is the shape a manual-assignment API should take — the consumer describes what each child is, the component decides where it goes.
panelSlot.assign(panels[this.#index] ?? null) deserves a note: passing no arguments, or a nullish value spread away, clears the slot and reveals its fallback content. assign() with zero arguments is the documented way to empty a slot.
Verification
const strip = document.querySelector('tab-strip');
const panelSlot = strip.shadowRoot.querySelector('slot[name="panel"]');
console.assert(strip.shadowRoot.slotAssignment === 'manual', 'expected manual mode');
console.assert(panelSlot.assignedElements().length === 1, 'exactly one panel projects');
console.assert(panelSlot.assignedElements()[0] === strip.children[2], 'first panel is active');
strip.show(1);
console.assert(panelSlot.assignedElements()[0] === strip.children[3], 'second panel is active');
// The decisive assertion: consumer markup was never modified.
console.assert(
[...strip.children].every((child) => !child.hasAttribute('slot')),
'manual assignment must not write slot attributes'
);
In DevTools, expand the host’s #shadow-root and hover the <slot name="panel"> element. The panel it currently projects highlights in the viewport, and the badge next to the slot updates as you call show() from the console — without the Elements panel showing any attribute-change flash on the light-DOM sections, which is the visual signature that the consumer’s markup is untouched.
When to Use vs When to Avoid
| Scenario | Recommendation |
|---|---|
| Component chooses which of N children to display | Manual — this is the case the mode exists for |
| Projection order must differ from markup order | Manual — argument order defines paint order |
| Consumer nodes are owned by a framework that re-renders | Manual — no attribute for the framework to overwrite |
| Content must render before scripts load | Named — manual mode projects nothing without script |
| Component must serialize to declarative markup | Named — manual assignment is not expressible in HTML |
| Simple, stable regions (header, body, footer) | Named — manual adds cost with no benefit |
| Targeting Safari before 16.4 or Firefox before 92 | Named, or ship the dual-mode fallback above |
The support floor is the one hard constraint: Chromium 86, Firefox 92, Safari 16.4. Everything older ignores slotAssignment and silently uses named mode — which means a component that assumes manual behaviour renders empty rather than falling back. Feature-detect with 'assign' in HTMLSlotElement.prototype, exactly as the pattern above does, rather than sniffing the shadow root’s slotAssignment value after the fact.
Frequently Asked Questions
Can I switch a shadow root between named and manual assignment at runtime?
No. slotAssignment is fixed when attachShadow runs and shadowRoot.slotAssignment is read-only. Choosing the mode is a one-time construction decision, so a component that needs both behaviours has to branch at construction time as the dual-mode example does.
Does assign() add to the existing assignment or replace it?
It replaces. The argument list becomes the slot’s complete set of manually assigned nodes, so incremental updates must pass the whole new list. Calling assign() with no arguments clears the slot and reveals its fallback content.
Do slot attributes still do anything in manual mode?
Nothing at all. They remain in the DOM and are still visible to getAttribute, CSS attribute selectors, and serialization, but the assignment algorithm never reads them. This is the usual cause of a component rendering completely empty right after the mode is switched on.
Can I assign a node that is not a child of the host?
You can call assign() with it and the node is stored, but composition skips it — nothing renders and no error is raised. Only nodes whose parent is the host participate in the flattened tree, in manual mode exactly as in named mode.
Related
- Slot Composition & Content Projection — the parent topic covering both assignment modes.
- Core Architecture & Lifecycle Management — the section this deep dive belongs to.
- Detecting Slot Changes with slotchange — the event, which behaves the same in both modes.
- Providing Fallback Content for Empty Slots — what renders when
assign()clears a slot. - Open vs Closed Shadow DOM Tradeoffs — the other
attachShadowoption that is fixed at construction.