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.

One global registry versus per-tree scoped registries With a single window registry a tag name can only mean one thing on the page, while a scoped registry attached to a shadow root lets the same name resolve to a different class inside that tree. Global registry only window.customElements ds-button → ButtonV2 ds-button throws a name means one thing per page workarounds: guard, rename, iframe all three have real costs Scoped registries window registry: ds-button → V2 widget shadow root, own registry ds-button → ButtonV1 same markup, different class no rename, no iframe resolution follows the tree

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.

Debugging Pitfall — the element that renders as an unknown tag. Because the scoped registry does not fall back to the global one, a widget that defines three of its four components in the scoped registry renders the fourth as an inert unknown element: no shadow root, no behaviour, and no console message. The symptom looks like a failed import. Enumerate the component set in one place and register the whole set into the scoped registry from that list, so a missing entry is impossible rather than merely unlikely.
How a tag name is resolved inside a scoped tree Lookup consults the registry associated with the containing tree and does not fall back to the window registry, so an unregistered tag stays undefined inside that tree. parser meets <ds-button> which tree contains it? document tree scoped shadow tree window.customElements ds-button → ButtonV2 upgrades normally the tree's own registry ds-button → ButtonV1 no fallback to the window

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.

What each isolation mechanism actually isolates A scoped registry isolates tag names only, shadow DOM adds style and structure isolation, and only a separate realm such as an iframe isolates scripts and globals. Pick the smallest mechanism that covers your threat scoped registry tag names styles, scripts and globals still shared shadow DOM structure and styles scripts and globals shared iframe / separate realm names, styles, scripts, globals — and a whole document to pay for Most embedded widgets need the first two together: a scoped registry inside a shadow root. Reach for a realm only when the host's scripts are genuinely untrusted, not merely unknown.

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.