Graceful Degradation Without JavaScript
A custom element that renders nothing until its JavaScript upgrades it is a broken element in every context where that script is delayed, blocked, or never arrives. The fix is to author meaningful light-DOM content that a <slot> projects, suppress the flash of undefined elements with :not(:defined), and design the base HTML so it is useful before any upgrade runs.
The problem: a blank component before upgrade
Consider a tabs component whose entire content lives inside the shadow root, generated at construction time. Before the class is defined — or if the bundle 404s — the user sees nothing at all:
<!-- The page as authored. Until JS runs, this is an empty, unknown element. -->
<x-tabs>
<!-- no light-DOM children: everything is "inside" the script -->
</x-tabs>
// x-tabs.js — generates all content imperatively, so pre-upgrade there is nothing.
class XTabs extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
// The ONLY source of visible content is this string. No JS, no content.
this.shadowRoot.innerHTML = `
<div role="tablist">
<button>Overview</button><button>Pricing</button>
</div>
<section>Overview text…</section>
<section hidden>Pricing text…</section>`;
}
}
customElements.define('x-tabs', XTabs);
If the script is blocked by a content-security policy, dropped by a flaky network, tree-shaken away by a misconfigured bundler, or simply slow, the <x-tabs> element occupies zero meaningful space and conveys zero information. A crawler that does not execute scripts indexes an empty node. This is the failure mode progressive enhancement exists to prevent: the experience should degrade, not disappear.
Root-cause analysis
The blankness is a direct consequence of where the content lives relative to the upgrade boundary. Per the WHATWG DOM Standard, a custom element exists in an undefined state from the moment the parser creates it until customElements.define() runs its upgrade reaction. In that window the element is an ordinary HTMLElement with no shadow root and no special behavior. Anything the component generates in its constructor or connectedCallback — including the entire shadow tree above — simply does not exist yet.
Light-DOM children, by contrast, are parsed and rendered immediately, because they are real document content the tokenizer emits before any script runs. The problem is twofold. First, the example component has no light-DOM children, so there is nothing to fall back to. Second, even when an element does have light-DOM children, those children are unstyled and may be positioned wrongly until the shadow root with its <slot> is attached, producing a flash of unstyled or mis-laid-out content (FOUC). The <slot> element is the projection mechanism defined by the Shadow DOM specification: it renders its assigned light-DOM nodes in place, but it only exists once the shadow root is attached. Designing for graceful degradation therefore means putting real, meaningful content in the light DOM and ensuring it is presentable both before and after the slot starts projecting it. The boundary mechanics behind this are detailed in Shadow DOM Construction & Modes.
The production-safe fix
Author the component’s content as semantic light-DOM HTML, then have the shadow root project it through slots. The same markup is meaningful as plain HTML and serves as slotted content after upgrade. Critically, the light-DOM fallback is the source of truth; the shadow root only adds presentation and interactivity.
<!-- The base HTML is a complete, usable document fragment on its own. -->
<x-tabs>
<h3 slot="tab">Overview</h3>
<div slot="panel">
<p>Our platform unifies your design tokens across every framework.</p>
</div>
<h3 slot="tab">Pricing</h3>
<div slot="panel">
<p>Plans start at $0 for open-source projects.</p>
</div>
</x-tabs>
// x-tabs.js — enhances existing light-DOM content; never invents it.
class XTabs extends HTMLElement {
#controller = new AbortController();
connectedCallback() {
// Adopt an SSR-provided shadow root if present; otherwise create one.
const root = this.shadowRoot ?? this.attachShadow({ mode: 'open' });
if (!root.childElementCount) {
root.innerHTML = `
<style>
:host { display: block; }
[part="tablist"] { display: flex; gap: .5rem; }
::slotted([slot="tab"]) { margin: 0; cursor: pointer; }
::slotted([slot="panel"]) { display: none; }
::slotted([slot="panel"].active) { display: block; }
</style>
<div part="tablist"><slot name="tab"></slot></div>
<slot name="panel"></slot>`;
}
const tabs = [...this.querySelectorAll('[slot="tab"]')];
const panels = [...this.querySelectorAll('[slot="panel"]')];
const activate = (i) => panels.forEach((p, j) => p.classList.toggle('active', i === j));
activate(0);
tabs.forEach((tab, i) =>
tab.addEventListener('click', () => activate(i), { signal: this.#controller.signal }));
}
disconnectedCallback() {
this.#controller.abort(); // tear down listeners on removal
}
}
customElements.define('x-tabs', XTabs);
The matching stylesheet, loaded in the light DOM, governs the pre-upgrade appearance and suppresses any flash. The :not(:defined) selector matches the element only while it is undefined, so it styles exactly the degradation window and then yields automatically:
/* Before upgrade: present the light-DOM content as a readable, stacked block. */
x-tabs:not(:defined) {
display: block;
}
x-tabs:not(:defined) [slot="tab"] {
font-weight: 700;
margin: 0.75rem 0 0.25rem;
}
x-tabs:not(:defined) [slot="panel"] {
display: block; /* show ALL panels — no JS to toggle them, so reveal everything */
}
/* After upgrade, :defined wins and the component's own styles take over. */
x-tabs:defined {
/* shadow styles now control layout */
}
Pre-upgrade, every panel is visible and every heading reads as a normal document — the content is fully accessible with no script. Post-upgrade, the component collapses the panels into an interactive tab set. If the script never loads, the user keeps the readable, stacked version; nothing is hidden behind an interaction that can no longer happen. This pairs naturally with server rendering: emitting the same markup with a declarative shadow root means the upgraded layout appears on first byte, as covered in Server-Side Rendering & Hydration.
Verification
Confirm degradation directly rather than assuming it. In Chrome DevTools, open the command menu and run Disable JavaScript, then reload. The <x-tabs> element must still show every heading and every panel as readable prose — that is the :not(:defined) path rendering. Re-enable JavaScript and reload: the element should collapse to the interactive tab set with only the active panel visible.
Assert it programmatically too. The light DOM must contain real content independent of the shadow root:
const el = document.querySelector('x-tabs');
// Fallback content exists in the light DOM regardless of upgrade state.
console.assert(el.querySelectorAll('[slot="panel"]').length === 2, 'panels present in light DOM');
console.assert(el.textContent.includes('design tokens'), 'meaningful text without JS');
// Slots actually project that content once upgraded.
await customElements.whenDefined('x-tabs');
const slot = el.shadowRoot.querySelector('slot[name="panel"]');
console.assert(slot.assignedElements().length === 2, 'panels are projected through the slot');
Finally, fetch the page with a script-free client (for example curl piped to a parser, or any crawler-style request) and confirm the response body already contains the headings and panel text. If it does, the base HTML is meaningful without JavaScript; if the body is an empty <x-tabs></x-tabs>, the content still lives only inside the script and the degradation has failed.
When to use and when to avoid
| Situation | Decision |
|---|---|
| Content-driven, SEO-sensitive, or accessibility-critical UI | Always — author meaningful light-DOM fallback projected through slots |
| Component wraps content the author already has as HTML (text, links, forms) | Always — slot it; the fallback is free |
| Audience includes blocked-script, slow-network, or crawler contexts | Always — the fallback is the only experience those users get |
| Purely decorative or purely interactive widget with no static meaning | Provide at least a labeled placeholder so the absence is intelligible, not blank |
| Component genuinely cannot exist without script (e.g. a live canvas chart) | Render a static summary or <noscript> message rather than an empty element |
You rely on :not(:defined) to hide content until upgrade |
Avoid hiding meaningful content — hide only chrome that is unusable without JS |
The discipline is to make the light DOM the source of truth and the shadow root an enhancement layered over it. When that ordering holds, a failed or delayed upgrade costs the user some interactivity but never the content itself.
Related
- Polyfills & Progressive Enhancement — the parent topic covering feature detection and scoping-shim limits.
- Polyfilling Custom Elements in Legacy Browsers — installing missing primitives when script does run.
- Server-Side Rendering & Hydration — declarative Shadow DOM that paints the upgraded layout on first byte.
- Shadow DOM Construction & Modes — how slots project light-DOM children across the boundary.
- Distribution, Testing & Tooling — the parent section for shipping resilient components.