Every Framework Gets Component Namespacing for Free: What Scoped Registries Change for Web Components
Source: chrome-devblog
React, Vue, Svelte, Solid, pretty much every modern component framework resolves component names through the module system. You import a component and use it. Two parts of the same app can import different Button components from different packages, and there is no conflict, because resolution happens at the import site, not at a global runtime dictionary.
Web components took a different path. customElements.define('my-button', MyButton) writes to a document-level singleton, and that name belongs to whoever wrote it first. The design made sense for one specific use case: enabling custom elements to be authored in HTML templates without imports, the same way <video> or <dialog> work. But it created a naming problem that grew worse as the ecosystem matured.
Chrome 146 shipped scoped custom element registries on March 10, 2026, and the API is deliberately minimal. CustomElementRegistry is now constructible, and attachShadow accepts a registry option. The rest is lookup chain rules. But the backstory runs deeper than a new constructor.
Why Frameworks Avoided This Problem
React’s component model ties component identity to the imported class or function. When you write <Button> in JSX, the compiler resolves it to whatever Button is in scope at that module. Two different files can both import Button from different packages and render completely different components under the same JSX element name, because the resolution is local to each module. There is no global dictionary involved.
Vue’s component registration has both a global registry and a local one. The local registry is the default for components declared inside <script setup>: you import a component, and the template can use it directly. The global registry exists but is explicitly opt-in, and most Vue codebases avoid it for exactly the reasons that bite web components, because global registration couples every component’s name into a shared namespace.
The structural difference is that framework component names are JavaScript identifiers at their core. Web component element names are strings that live in HTML. HTML templates do not have import statements. A <fancy-button> in a Lit template or a plain HTML file has to resolve to something without the module graph’s help, and a global registry is the natural mechanism for that.
This was a deliberate tradeoff. The payoff was that web components could work in declarative HTML without JavaScript wiring. The cost was that element names became a global resource contended by everything on the page.
What Years of Polyfills Looked Like
The Open Web Components project shipped ScopedElementsMixin for Lit before the spec existed. The implementation was an exercise in creative circumvention. Rather than touching customElements.define directly, it intercepted every innerHTML assignment and createElement call, rewrote element names to unique mangled forms (something like corp-button-a3f2b1 derived from a hash), and then registered the mangled names globally.
This worked, but introduced problems at every boundary. Server-side rendering required the mangled names to survive serialization and deserialization, which meant the SSR layer needed to know about the mangling scheme. DevTools showed the mangled names, making debugging awkward. The interception layer added overhead to every DOM mutation. Nested shadow roots required tracking which scope was active at each call site.
The @webcomponents/scoped-custom-element-registry polyfill took a different approach: monkey-patching customElements.define itself, tracking which registry was “active” via a stack that got pushed and popped around shadow root operations. It was more faithful to the eventual spec but equally fragile, because monkey-patching the browser’s upgrade machinery meant every edge case in element construction needed to be handled in userspace.
Native support bypasses both approaches entirely. The browser’s HTML parser hooks directly into the registry lookup during element upgrade. No interception layer, no name rewriting, no monkey-patching. This also means template cloning works correctly: when you clone a <template> and append it into a shadow root with a scoped registry, custom elements in the cloned content upgrade against the scoped registry, not the global one.
The New API
CustomElementRegistry is now directly constructible, and instances have the same interface as window.customElements:
const registry = new CustomElementRegistry();
registry.define('corp-button', CorpButton);
registry.define('corp-icon', CorpIcon);
The scoped registry attaches to a shadow root via an option passed to attachShadow:
class CorpWidget extends HTMLElement {
constructor() {
super();
const registry = new CustomElementRegistry();
registry.define('corp-button', CorpButton);
registry.define('corp-icon', CorpIcon);
this.attachShadow({ mode: 'open', registry });
}
connectedCallback() {
this.shadowRoot.innerHTML = `
<corp-button>Submit</corp-button>
<corp-icon name="check"></corp-icon>
`;
}
}
customElements.define('corp-widget', CorpWidget);
The corp-button and corp-icon inside the shadow root resolve against the scoped registry. The global window.customElements is unaffected. Two different widgets can each define their own corp-button pointing to different classes, and both work correctly on the same page.
The lookup chain is hierarchical: the browser checks the shadow root’s registry first, then walks up through any enclosing shadow roots, then falls back to window.customElements. Elements defined globally remain accessible from within scoped contexts; you only need to define them in the scoped registry if you want a local version to take precedence.
whenDefined works on scoped instances the same way it works on the global registry:
const registry = new CustomElementRegistry();
const shadow = host.attachShadow({ mode: 'open', registry });
registry.define('lazy-component', LazyComponent);
await registry.whenDefined('lazy-component');
// safe to query or manipulate instances
One constraint worth noting: scoped registries are still write-once per name. Calling define() twice with the same name in the same registry throws NotSupportedError, same as the global registry. The isolation is between registries, not within them.
The Shadow DOM Constraint Is Not Incidental
Scoped registries only work inside shadow roots. There is no way to scope element definitions in the main document’s light DOM. This constraint follows from the architecture: scoped registries need a root node to anchor the scope boundary, and shadow roots are the only construct the platform provides for that purpose.
For most component library use cases, this is not a limitation. A component that attaches a shadow root can scope all its internal elements privately. Multiple versions of the same library can coexist as long as each host element uses its own shadow root with its own registry.
For micro-frontend architectures that render into light DOM, this matters more. Frameworks like single-spa render application shells into regular DOM nodes by default, because shadow DOM’s style encapsulation is often unwanted at the application shell level. Those setups cannot use scoped registries for their top-level element definitions; the global registry remains the only option there.
This is not a design flaw. The scoping model mirrors how shadow DOM already works: it provides isolation for subtrees, not for the flat document structure. Web components were always most useful within shadow root boundaries, and scoped registries reinforce that architectural grain.
Browser Support and the Polyfill Path Forward
Chrome 146 ships scoped registries as stable. Firefox and Safari have not committed to a timeline as of March 2026. The WICG proposal has been tracking for years, so the spec is stable, but a stable spec can sit unimplemented across browsers for a long time.
The @webcomponents/scoped-custom-element-registry polyfill remains necessary for cross-browser coverage. The good news is that the polyfill now has a stable target spec to implement against. The bad news is that polyfill overhead is back: the native implementation’s advantage is precisely that it hooks into the parser and upgrade machinery directly, and a polyfill cannot replicate that without interception layers.
Library authors who need correct behavior in Firefox and Safari are still on the polyfill path. The practical guidance is to write against the native API, declare a polyfill dependency, and plan to drop it when browser support stabilizes.
What Changes for Library Authors
The most direct win is internal encapsulation. If you’re building a component library, you can now define all internal helper components in scoped registries attached to each top-level component’s shadow root, and nothing leaks into the global namespace. Your library ships <corp-widget> to the global registry and manages its own <corp-button>, <corp-icon>, and <corp-spinner> privately.
The multi-version coexistence story is real but requires migration. Both versions of a library need to be written to use scoped registries. A library that calls customElements.define globally for its internals does not benefit from scoped registries regardless of what the host page does. This means the tooling payoff comes as libraries update to the new pattern, not immediately upon Chrome shipping support.
Third-party embedded content (chat widgets, comment threads, embedded analytics dashboards) has the clearest path forward. Anything that ships as a web component intended to live alongside arbitrary host page code can now scope its internal elements without risking collisions with whatever the host page defines.
Web components took the global registry approach because the payoff was HTML-native declarative component use. Scoped registries do not undo that tradeoff. You can still use custom elements in raw HTML without imports. They add an opt-in isolation layer for the cases where the global registry’s constraints become costs, and they do it natively, without the fragile interception workarounds that the ecosystem spent years maintaining.
Frameworks got this for free through the module graph. Web components are getting it now through the DOM.