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.

Attribute rewriting versus manual assignment Named assignment forces the component to mutate consumer markup, while manual assignment keeps the mapping inside the shadow tree. Named: component writes attributes consumer markup shadow slots setAttribute('slot', …) consumer observers fire framework re-render wins serialization captures state markup is no longer declarative Manual: component holds the mapping consumer markup slot.assign(node) markup untouched no consumer mutations order is component-defined state lives in the component re-render cannot clobber it

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:

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.

Debugging Pitfall — the empty component after a mode switch. Adding 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.

Decision tree for choosing an assignment mode A decision tree routing from who decides placement, whether order must differ from markup order, and whether server rendering is required, to named or manual assignment. Who decides placement? consumer component Needs SSR or no script? Order differs from markup? Named assignment declarative, serializable Named assignment simplest default Manual assignment component owns mapping Manual assignment reorder without DOM moves Manual mode requires script: plan a named fallback for no-JS delivery.

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.

Support timeline for named versus manual slot assignment Named assignment shipped across all three engines by 2018 while manual assignment arrived between 2020 and 2023, leaving a support gap that silently degrades to named behaviour. Support floor: the only hard constraint on manual mode 2016 2018 2020 2022 2024 named universally available Chromium 86 Firefox 92 Safari 16.4 Older engines ignore slotAssignment silently: a manual-only component renders empty, it does not throw.

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.