Web Components Finally Get a Namespacing Story: What Scoped Registries Actually Change
Source: chrome-devblog
The global customElements registry has been a quiet source of frustration since web components shipped in earnest around 2018. It works fine in isolation. It falls apart the moment two different things on the same page want to use the same element name, which is a constraint that becomes increasingly painful as component libraries proliferate and micro-frontends become normal.
Chrome has now shipped scoped custom element registries, and the spec change is simpler than it might sound. The short version: CustomElementRegistry is now constructible, and attachShadow accepts a registry option. Everything else follows from that.
The Problem With One Global Registry
The current customElements API is a document-scoped singleton. You call customElements.define('my-button', MyButton) once, and that name is reserved for the lifetime of the page. Try to define it again and you get a NotSupportedError. There’s no versioning, no namespacing, no way for two independent parts of a page to use their own <my-button> that resolves to different classes.
This is fine for a simple app where one team controls everything. It breaks in at least three practical scenarios:
First, component library versioning. If your app depends on version 1.0 of @corp/design-system and one of your lazy-loaded micro-frontend shells depends on version 2.0, and both define <corp-button>, you have a problem. Whichever loads first wins. The other silently gets whatever behavior the first version implemented.
Second, third-party widgets. An embedded chat widget or payment form that ships its own web components has to pick names that won’t collide with anything else on the host page. The only mechanism available is aggressive prefixing, which works until it doesn’t.
Third, design system composition. If you’re building a component that internally uses other components, there’s no clean way to bundle those internal dependencies without exposing them to the global registry. Your <fancy-dialog> silently defines <fancy-button> into the document’s namespace as a side effect.
How the New API Works
The core change is that CustomElementRegistry is now directly constructible:
const registry = new CustomElementRegistry();
registry.define('corp-button', CorpButtonV2);
That registry stays inert until you associate it with a shadow root:
class MyWidget extends HTMLElement {
constructor() {
super();
const registry = new CustomElementRegistry();
registry.define('corp-button', CorpButtonV2);
this.attachShadow({ mode: 'open', registry });
}
connectedCallback() {
this.shadowRoot.innerHTML = `
<corp-button>Submit</corp-button>
`;
}
}
customElements.define('my-widget', MyWidget);
The corp-button inside MyWidget’s shadow root resolves from the scoped registry. Whatever corp-button is registered globally (or not registered at all) is irrelevant inside that shadow root. Two different shadow roots can define corp-button as completely different classes with no conflict.
The lookup chain follows the shadow root hierarchy. When the browser encounters a custom element tag inside a shadow root, it checks the shadow root’s registry first, then walks up through any enclosing shadow roots, and finally falls back to the global customElements. This means you can override global definitions locally without removing them globally, and nested shadow roots can inherit from their parent’s scoped registry.
Upgrade and Observation
The existing customElements.get(), customElements.upgrade(), and whenDefined() methods all have equivalents on the scoped CustomElementRegistry instance. So if you’re dynamically creating elements within a scoped shadow root, you can wait for their definitions without going through the global registry:
const registry = new CustomElementRegistry();
registry.define('lazy-panel', LazyPanel);
const shadow = host.attachShadow({ mode: 'open', registry });
await registry.whenDefined('lazy-panel');
// safe to instantiate now
Template cloning also respects scoped registries. When you clone a <template> and append the result to a shadow root that has a scoped registry, custom elements within that cloned content upgrade against the scoped registry rather than the global one. This is the detail that makes the feature actually composable at the library level.
Prior Art: Lit’s Scoped Element Mixin
This isn’t the first attempt at solving this. The Lit team shipped a ScopedElementsMixin through the Open Web Components project years before the spec existed. It worked by intercepting innerHTML and createElement calls on the shadow root and swapping element names through a generated mapping table. So if your scoped definition used corp-button, it would get rewritten to something like corp-button-a3f2b1 in the actual DOM, and the mixin would define that mangled name globally.
It was a clever workaround and it mostly worked, but it had real costs. SSR was difficult because the mangled names needed to survive serialization and deserialization. Debugging was messy because the names in DevTools didn’t match the names in your source. Performance overhead from the interception layer was nonzero. The spec-level implementation sidesteps all of this by making the registry lookup happen at the parsing and upgrade stages rather than through DOM manipulation.
What It Doesn’t Solve
Scoped registries only apply inside shadow roots. Light DOM custom elements still go through the global registry. If you have a custom element in the main document and you want scoped definitions, there’s no path. The shadow DOM requirement is load-bearing here: the registry lookup chain needs a root to anchor to, and shadow roots provide that structure.
This means micro-frontends that render into light DOM (which is common with approaches like single-spa’s default configuration) can’t use scoped registries for their components. You still need aggressive prefixing or some form of runtime coordination. The feature is primarily for component library authors who render into shadow roots, not for arbitrary page composition patterns.
There’s also no mechanism for querying which registry a given element was defined in, or for moving a definition between registries after the fact. Registries are write-once: define() throws if you try to redefine a name in the same registry. You can have multiple registries with the same name mapping to different classes, but each registry itself is append-only.
What This Means in Practice
For component library authors, this changes the calculus around bundling internal dependencies. A library can now ship a MyWidget that internally uses InternalButton and InternalIcon, define all three in a scoped registry attached to MyWidget’s shadow root, and expose only MyWidget to the consumer. The internal helpers don’t touch the global namespace. Two versions of the library can coexist on the same page as long as they use separate shadow roots.
For design system teams working in micro-frontend contexts, this is the feature that makes web components viable for gradual migration. Teams can independently version their components without coordinating on a shared registry or adopting a complex build-time deduplication strategy.
React and Vue don’t have this problem at all, for what it’s worth. Their component registries are module-scoped by default. You import Button from './Button' and the component tree resolves references through the module graph, not a global runtime dictionary. Web components made a different tradeoff when they chose declarative HTML-based element names, and scoped registries are the long-overdue correction for the cases where that tradeoff costs you.
Browser support is currently Chrome-only. The spec is in good shape, and the other vendors will catch up, but there’s no timeline that’s publicly committed to. In the meantime, the Open Web Components polyfill remains a workable bridge for the teams that need this today.