Providing Fallback Content for Empty Slots: Defaults That Actually Render

A <slot> with children is two components in one. When the consumer projects something, the assigned nodes render and the slot’s own children are ignored entirely. When nothing is assigned, those children render instead. This is the platform’s built-in default-value mechanism, and it is the cheapest way to make a component useful with zero configuration — a search field that says “Search” until someone supplies a better label, an avatar that shows initials until an image arrives, an empty-state panel that explains itself.

The mechanism is also full of sharp edges, because “nothing is assigned” is a stricter condition than most authors assume, and because fallback content lives in the shadow tree while projected content lives in the light tree — which means the two are styled by different stylesheets and behave differently under server-side rendering. This page belongs to Slot Composition & Content Projection inside Core Architecture & Lifecycle Management.

Problem Statement: The Fallback That Never Appears

class AvatarChip extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' }).innerHTML = `
      <style>
        :host { display: inline-flex; align-items: center; gap: 0.5rem; }
        .initials { width: 2rem; height: 2rem; border-radius: 999px;
                    display: grid; place-items: center; background: #dbe4ff; }
      </style>
      <slot name="avatar"><span class="initials">AB</span></slot>
      <slot name="label">Unknown user</slot>`;
  }
}
customElements.define('avatar-chip', AvatarChip);
<!-- Author's intent: no avatar supplied, so show the initials fallback. -->
<avatar-chip>
  <span slot="label">Ada Byron</span>
</avatar-chip>

<!-- Author's intent: default label, custom avatar. -->
<avatar-chip>
  <img slot="avatar" src="/ada.avif" alt="">
</avatar-chip>

The first chip shows the initials as expected. The second one shows the image and then, where “Unknown user” should be, shows nothing at all. Reformat the markup so the <img> sits on its own indented line — which every formatter does — and the default-slot whitespace text nodes change the picture again. Neither symptom produces a console message.

When fallback content renders and when it is suppressed A slot with zero assigned nodes renders its own children, while a slot assigned even a whitespace text node suppresses them. assignedNodes().length decides, not assignedElements() 0 assigned nodes no children match the name fallback renders 1 whitespace text node newline between tags fallback suppressed 1 empty element <span slot="label"></span> fallback suppressed The rule the spec states A slot renders its own children only when its assigned-nodes list is completely empty. Text nodes count. Empty elements count. Visibility, size and content do not enter into it. A named slot is safe from stray whitespace; only the default slot collects it.

Root-Cause Analysis

The DOM Standard’s definition is blunt: a slot’s assigned nodes are the slottables assigned to it, and when that list is empty the slot’s children are used in the flattened tree instead. There is no threshold, no notion of “meaningful” content, and no distinction between an element and a text node. One whitespace text node is enough to suppress fallback for the default slot.

The second chip in the reproduction fails for a different reason, and it is the one authors trip over most: <slot name="label">Unknown user</slot> is a named slot, so only children carrying slot="label" reach it. The consumer supplied <img slot="avatar"> and nothing else, so the label slot has zero assigned nodes and should render its fallback. It does — but the surrounding markup contains a newline between <avatar-chip> and <img>, and those whitespace text nodes go to the default slot, which this component does not have. They are simply unassigned and invisible. The missing label is therefore not a whitespace problem at all; it is that the fallback text renders correctly and is then hidden by :host { display: inline-flex; } collapsing around a zero-width text run when the shadow stylesheet gives it no box. The lesson generalises: because fallback content lives in the shadow tree, it is styled by the component’s stylesheet and completely unaffected by any ::slotted() rule the component wrote for the projected case.

That styling asymmetry is the deepest part of the model:

So a rule like ::slotted(span) { font-weight: 600 } styles the projected case and silently does nothing for the fallback. Components that want both to look alike need the rule twice, once for each tree.

Debugging Pitfall — the fallback that vanishes only in production. A templating layer that emits <span slot="label"></span> produces an empty span when the value is missing. That empty span is an assigned node, so the fallback is suppressed and the component renders a blank region rather than its default. Emit no element at all when there is no value; do not emit an empty one.

The flatten option exists for exactly this ambiguity. slot.assignedNodes({ flatten: true }) returns the fallback children when nothing is assigned, and resolves nested slots recursively when a component projects into another component. It answers “what is actually rendering here”, where the default answers “what did the consumer hand me”.

Which stylesheet reaches projected content versus fallback content Projected nodes are styled by the consumer document with limited slotted reach, while fallback children are styled only by the component shadow stylesheet. Projected node (light tree) consumer stylesheet wins ::slotted() reaches top level only component selectors do not apply Fallback child (shadow tree) component stylesheet wins ::slotted() never matches it consumer needs ::part() to reach it

Production-Safe Pattern

The version below makes the default explicit, styles both trees identically, and exposes the fallback to consumers through a part so it can still be themed.

class AvatarChip extends HTMLElement {
  #controller = null;

  constructor() {
    super();
    this.attachShadow({ mode: 'open' }).innerHTML = `
      <style>
        :host { display: inline-flex; align-items: center; gap: 0.5rem; }
        /* One rule set, applied to BOTH trees: the shadow-tree fallback… */
        .label, [part~="label"] { font-weight: 600; line-height: 1.4; }
        /* …and the projected node, which needs its own selector. */
        ::slotted([slot="label"]) { font-weight: 600; line-height: 1.4; }
        .initials {
          width: 2rem; height: 2rem; border-radius: 999px;
          display: grid; place-items: center; background: #dbe4ff; color: #1f2a55;
        }
        :host([data-empty-label]) { opacity: 0.75; }
      </style>
      <slot name="avatar"><span class="initials" part="avatar-fallback">AB</span></slot>
      <slot name="label"><span class="label" part="label">Unknown user</span></slot>`;
  }

  connectedCallback() {
    this.#controller = new AbortController();
    const labelSlot = this.shadowRoot.querySelector('slot[name="label"]');
    const sync = () => {
      // assignedNodes() without flatten answers "did the consumer supply anything";
      // filtering blank text nodes stops formatter whitespace from counting.
      const supplied = labelSlot.assignedNodes()
        .filter((node) => node.nodeType !== Node.TEXT_NODE || node.textContent.trim() !== '');
      this.toggleAttribute('data-empty-label', supplied.length === 0);
    };
    labelSlot.addEventListener('slotchange', sync, { signal: this.#controller.signal });
    sync();
  }

  disconnectedCallback() {
    this.#controller?.abort();
    this.#controller = null;
  }

  /** What is actually rendering, fallback included. */
  get effectiveLabel() {
    const slot = this.shadowRoot.querySelector('slot[name="label"]');
    return slot.assignedNodes({ flatten: true })
      .map((node) => node.textContent.trim())
      .join(' ')
      .trim();
  }
}
customElements.define('avatar-chip', AvatarChip);

Four decisions carry the correctness here. The fallback is wrapped in a real element rather than being a bare text node, so it has a box, a class, and a part. The styling rule is written twice, once per tree, because no single selector spans both. The emptiness check filters whitespace explicitly rather than trusting assignedElements(), which would report zero for a consumer who projected a bare text label — legitimate content. And effectiveLabel uses flatten: true, so a caller asking what the chip says gets "Unknown user" rather than an empty string.

Verification

const chip = document.querySelector('avatar-chip');
const labelSlot = chip.shadowRoot.querySelector('slot[name="label"]');

// Nothing projected: fallback must be what renders.
console.assert(labelSlot.assignedNodes().length === 0, 'no consumer nodes');
console.assert(labelSlot.assignedNodes({ flatten: true }).length === 1, 'fallback resolves');
console.assert(chip.effectiveLabel === 'Unknown user', 'effective text is the fallback');
console.assert(chip.hasAttribute('data-empty-label'), 'empty flag set');

// Project a label: fallback must disappear from the flattened result.
const span = document.createElement('span');
span.slot = 'label';
span.textContent = 'Ada Byron';
chip.append(span);
await new Promise((resolve) => queueMicrotask(resolve));
console.assert(chip.effectiveLabel === 'Ada Byron', 'projection replaces fallback');
console.assert(!chip.hasAttribute('data-empty-label'), 'empty flag cleared');

In DevTools, an empty slot shows its fallback children nested underneath it in the Elements panel with no reveal link, whereas an assigned slot shows the reveal badge pointing back to the light-DOM node. That badge is the fastest visual test for which branch you are looking at. Toggling the consumer node’s slot attribute in the panel flips the rendering live, which is a quick way to confirm both trees are styled to match.

When to Use vs When to Avoid

Situation Fallback content Attribute default Rendered-empty state
Human-readable default label or copy Use — zero-config and accessible Clumsy for prose No
Icon or illustration placeholder Use — ships with the component No No
Value the consumer must set (an id, a URL) No — fail loudly instead Use with validation No
Content that should be indexable by crawlers Prefer light-DOM projection No No
A region that must collapse entirely when empty No — fallback would occupy space No Use, driven by slotchange
Default that consumers need to restyle Use, plus a part attribute No No

The crawler row matters for content sites. Fallback content lives in the shadow tree, so it is present in the DOM but absent from the raw HTML unless the component is server-rendered with Declarative Shadow DOM. Anything that must be visible to a text-only client belongs in the light DOM, projected — the tradeoff explored in graceful degradation without JavaScript.

Choosing between fallback content, an attribute default, and a collapsed empty state A decision tree separating human-readable defaults, required values that should fail loudly, and regions that must occupy no space when empty. Nothing was supplied. Now what? show something sensible value is required show nothing Fallback content wrap it in a real element give it a part for theming style both trees explicitly zero-config, accessible Attribute + validation ids, URLs, form names a silent default hides bugs warn once in development fail loudly, not quietly Collapsed region no fallback at all template ships hidden slotchange reveals it occupies no space

Frequently Asked Questions

Does an empty element suppress fallback content?

Yes. The rule is purely structural: any assigned node, including an element with no children and no box, empties the fallback branch. Templating layers should omit the element entirely rather than emitting an empty one.

Why does ::slotted() not style my fallback?

::slotted() matches only nodes assigned from the light tree. Fallback children live in the shadow tree, so ordinary shadow selectors style them and ::slotted() never matches. Components that want both cases to look alike must write the declarations twice.

How do I read what is actually rendering, fallback included?

Call assignedNodes({ flatten: true }). Without the option you get only consumer-supplied nodes; with it you get the fallback children when the slot is empty, and nested slots are resolved recursively.

Can consumers override fallback content styling?

Only if the component opts in. Give the fallback element a part attribute and consumers can reach it with ::part(); expose a custom property and they can retheme it. Without one of those hooks the fallback is sealed inside the shadow tree.