Scoping Definitions with CustomElementRegistry: Escaping the Global Tag Namespace
customElements.define writes into a registry that belongs to the window. There is one of them, every script on the page shares it, a name can be claimed exactly once, and claiming a name twice throws NotSupportedError. That is a fine model for an application that owns its whole page and a genuinely hard constraint for anything else: two versions of a design system in one page, a widget embedded in a host application that already uses the tag, a micro-frontend architecture where teams ship independently.
Scoped registries change the model. A CustomElementRegistry can be constructed independently and attached to a shadow root, so a tag name resolves differently inside that tree than it does outside it. This deep dive belongs to Custom Element Registry & Definition inside Core Architecture & Lifecycle Management.
Problem Statement: Two Versions, One Name
// Design system v2, loaded by the host application.
class ButtonV2 extends HTMLElement { /* … */ }
customElements.define('ds-button', ButtonV2);
// A widget bundle, published separately, still on v1.
class ButtonV1 extends HTMLElement { /* … */ }
customElements.define('ds-button', ButtonV1);
// Uncaught DOMException: Failed to execute 'define' on 'CustomElementRegistry':
// the name "ds-button" has already been used with this registry
The exception is the good outcome, because it is loud. The workarounds teams reach for are worse. Guarding with if (!customElements.get('ds-button')) makes the second bundle silently use the first bundle’s implementation, so a widget built against v1 renders v2 markup and behaves subtly differently. Renaming to ds-button-v1 works and leaks a version number into every consumer’s markup forever. Wrapping the widget in an iframe isolates it and costs a document, a network context, and every cross-boundary interaction.
Root-Cause Analysis
The HTML Standard has always defined CustomElementRegistry as an interface, with window.customElements merely being a registry rather than the registry — the global one associated with the window. What was missing was any way to obtain another one, and any way to say which registry a given tree should consult.
Scoped registries supply both. new CustomElementRegistry() constructs an independent registry with its own name-to-constructor map. element.attachShadow({ mode: 'open', customElements: registry }) associates that registry with the shadow root, and the specification’s look up a custom element definition algorithm then consults the registry of the node’s containing tree rather than the window’s.
Four consequences follow, and each is the answer to a question teams ask immediately.
Resolution is per tree, not per element. Everything inside that shadow root — including elements created there by the parser or by innerHTML — resolves through the scoped registry. Nested shadow roots inside it get their own association; they do not inherit it automatically.
The global registry is not consulted as a fallback. A tag that the scoped registry does not define stays undefined inside that tree, even if the window defines it. This is deliberate: partial inheritance would reintroduce exactly the ambiguity the feature removes. A scoped tree that wants a globally defined component must define it in the scoped registry too.
Creation must be registry-aware. document.createElement('ds-button') uses the document’s registry, so an element created that way and then inserted into a scoped tree does not upgrade to the scoped definition. Creation inside the scoped tree — via its own innerHTML, or via a registry-aware creation path — is what binds the element to the right definition.
Types cannot follow. HTMLElementTagNameMap is global by construction, so a tag with two meanings has no single global type. Components in a scoped registry should expose typed factory functions rather than relying on tag-string lookup, as noted in declaring HTMLElementTagNameMap entries.
Production-Safe Pattern
A widget that owns its own component set, registers all of it into a private registry, and renders inside a shadow root bound to that registry.
import { ButtonV1 } from './button-v1.js';
import { FieldV1 } from './field-v1.js';
import { PanelV1 } from './panel-v1.js';
/** The widget's complete component set, declared once so none can be missed. */
const COMPONENTS = Object.freeze({
'ds-button': ButtonV1,
'ds-field': FieldV1,
'ds-panel': PanelV1
});
const SUPPORTS_SCOPED = (() => {
try {
return typeof CustomElementRegistry === 'function' &&
Boolean(new CustomElementRegistry());
} catch {
return false;
}
})();
class LegacyWidget extends HTMLElement {
#registry = null;
constructor() {
super();
if (SUPPORTS_SCOPED) {
// A private namespace: these names mean OUR classes inside this tree only.
this.#registry = new CustomElementRegistry();
for (const [tag, ctor] of Object.entries(COMPONENTS)) {
this.#registry.define(tag, ctor);
}
this.attachShadow({ mode: 'open', customElements: this.#registry });
} else {
// Fallback: prefixed names in the global registry. Same markup shape,
// different tags, so the template is generated rather than hard-coded.
for (const [tag, ctor] of Object.entries(COMPONENTS)) {
const scoped = `legacy-${tag}`;
if (!customElements.get(scoped)) customElements.define(scoped, ctor);
}
this.attachShadow({ mode: 'open' });
}
this.shadowRoot.innerHTML = this.#template();
}
/** One template, rendered with whichever tag names this environment needs. */
#template() {
const tag = (name) => (SUPPORTS_SCOPED ? name : `legacy-${name}`);
return `
<style>:host { display: block; }</style>
<${tag('ds-panel')}>
<${tag('ds-field')} label="Search"></${tag('ds-field')}>
<${tag('ds-button')}>Go</${tag('ds-button')}>
</${tag('ds-panel')}>`;
}
/** Typed creation path: tag-string lookup cannot express two meanings. */
createButton() {
return SUPPORTS_SCOPED
? new (this.#registry.get('ds-button'))()
: new (customElements.get('legacy-ds-button'))();
}
}
customElements.define('legacy-widget', LegacyWidget);
Four properties make this shippable today. The component set is a frozen object, so registering the whole set is one loop and a missing entry is impossible. The scoped path and the prefixed-fallback path render from one template function, so the two code paths cannot drift structurally. Creation goes through the registry rather than document.createElement, which is the only way to get the scoped definition. And feature detection is a real construction attempt rather than a version check.
The fallback is worth taking seriously rather than treating as dead code: scoped registries are the newest part of the custom elements API and are not yet interoperable, so for most libraries the prefixed path is the one that actually runs in production today.
Verification
const widget = document.querySelector('legacy-widget');
if (widget.shadowRoot.customElements) {
// 1. The shadow root really has its own registry.
console.assert(
widget.shadowRoot.customElements !== window.customElements,
'shadow root uses a scoped registry'
);
// 2. The same tag resolves to different classes on each side of the boundary.
const inner = widget.shadowRoot.querySelector('ds-button');
const outer = document.querySelector('ds-button');
console.assert(inner.constructor !== outer?.constructor, 'two meanings, one name');
// 3. There is NO fallback: an undefined tag stays undefined inside the tree.
widget.shadowRoot.innerHTML += '<not-registered></not-registered>';
const stray = widget.shadowRoot.querySelector('not-registered');
console.assert(stray.constructor === HTMLElement, 'no global fallback inside the scope');
// 4. document.createElement uses the DOCUMENT registry, not the scoped one.
const created = document.createElement('ds-button');
widget.shadowRoot.append(created);
console.assert(created.constructor !== inner.constructor, 'creation path matters');
}
Assertion four is the one that catches the most real bugs. Code that builds a scoped tree with document.createElement looks correct, compiles, and produces elements bound to the wrong definition — visible only as behaviour that matches the host application’s version rather than the widget’s. In DevTools, expanding the shadow root and comparing the constructor names of an inner and outer element of the same tag confirms the scoping in one glance.
When to Use vs When to Avoid
| Situation | Approach |
|---|---|
| Two versions of one library on a page | Scoped registry, with a prefixed fallback |
| Widget embedded in an unknown host application | Scoped registry — the host’s tag names are unknowable |
| Micro-frontends shipping independently | Scoped registry per frontend |
| Single application owning its whole page | Global registry — scoping adds cost with no benefit |
| Library that must work in today’s browsers | Prefixed names, with scoped registries as an enhancement |
| Components needing global TypeScript types | Global registry — scoped tags cannot be typed by tag string |
| Complete isolation including CSS and script | An iframe, not a registry |
The last row draws the boundary honestly. A scoped registry isolates names, not styles, not scripts, and not the global object. A widget that also needs to defend against the host’s global stylesheet still needs shadow DOM for that, and one that needs to defend against the host’s JavaScript still needs a separate realm.
Frequently Asked Questions
Does a scoped registry fall back to the global one?
No. Lookup consults only the registry associated with the containing tree, so a tag the scoped registry does not define renders as an unknown element even when the window defines it. Register the complete component set into the scoped registry.
Why does document.createElement not produce the scoped definition?
Because it uses the document’s registry. Create elements through the scoped registry’s constructor or by writing markup inside the scoped tree, so the definition is resolved against the tree the element will live in.
Can I type a scoped element with HTMLElementTagNameMap?
Not meaningfully — that map is global, and a scoped tag has more than one meaning. Expose typed factory functions or import the class directly rather than relying on tag-string lookup.
What should a library do until scoped registries are interoperable?
Ship prefixed tag names in the global registry as the default path, feature-detect the constructor, and use a scoped registry where available. Generate the template from one tag-name function so the two paths cannot diverge.
Related
- Custom Element Registry & Definition — the parent topic on registration and upgrade.
- Core Architecture & Lifecycle Management — the section this deep dive belongs to.
- How to Define Custom Elements Without Frameworks — the global registration path this builds on.
- Shadow DOM Construction & Modes — the
attachShadowoptions a scoped registry joins. - Declaring HTMLElementTagNameMap Entries — why scoped tags resist global typing.