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.
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:
- Projected content is styled by the consumer’s document, with
::slotted(sel)giving the component a low-specificity say over the top-level node only. - Fallback content is styled by the component’s shadow stylesheet with ordinary selectors, and the consumer cannot reach it at all — not with a global rule, not with
::slotted(), and not with::part()unless the component adds a part attribute.
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.
<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”.
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.
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.
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 — driving the empty-state flag correctly.
- Flattening Nested Slot Trees with assignedElements() — what
flatten: trueresolves across components. - Part & Slotted Selectors — exposing fallback elements for consumer theming.