Scoped custom element registries end tag clashes
DEV Community

Scoped custom element registries end tag clashes

This note has an interactive demo. Try it in the original.

Two libraries can now define the same custom element name on one page without breaking each other. Scoped custom element registries became Baseline on 27 October 2026, when Firefox 159 shipped them. Chrome and Edge 146 and Safari 26 already had them.

The problem: one name, one page

Every custom element you've ever registered went into the same place: window.customElements, the global registry. Names in it have to be unique across the whole page. MDN describes the result: if two libraries both try to define <my-button>, one of them fails.

You can see it in the demo above. Switch it to "Global registry": Ember UI calls customElements.define('ui-button', …) first and wins. When Tide UI tries the same thing, the browser throws a NotSupportedError, and the log shows the real message from your browser. Tide UI's button then renders with Ember UI's class, because there's only one ui-button on the page. That's an orange pill where a teal one should be.

So far the fix has been social, not technical: prefix every tag (<ember-button>, <tide-button>) and hope nobody else picks the same prefix. That gets harder when the same page loads two versions of one design system, which can happen with micro-frontends.

The fix: a registry per shadow root

Now you can create as many registries as you want with new CustomElementRegistry() and attach each one to a shadow root. MDN explains that definitions in a scoped registry aren't added to the global one. They only apply to the nodes linked to that registry.

This is the core of the demo's "Scoped registries" mode:

const registryA = new CustomElementRegistry();
registryA.define('ui-button', EmberButton);
const registryB = new CustomElementRegistry();
registryB.define('ui-button', TideButton);
const shadowA = emberHost.attachShadow({ mode: 'open', customElementRegistry: registryA });
shadowA.innerHTML = '<ui-button></ui-button>'; // EmberButton
const shadowB = tideHost.attachShadow({ mode: 'open', customElementRegistry: registryB });
shadowB.innerHTML = '<ui-button></ui-button>'; // TideButton
customElements.get('ui-button'); // undefined: the global registry stays clean

The same tag now has two classes, two looks and two behaviours, and the global registry doesn't know about either. The demo's log checks this live: shadowA.customElementRegistry === registryA returns true.

Attaching the registry later

You don't always have the registry ready when the shadow root is created. You can pass customElementRegistry: null, add your markup, and connect the registry later with initialize(). That's the "Lazy island" panel in the demo:

const island = islandHost.attachShadow({ mode: 'open', customElementRegistry: null });
island.innerHTML = '<ui-button></ui-button>'; // still :not(:defined)
registryB.initialize(island); // now it's a TideButton

Until you call initialize(), the island's <ui-button> elements exist in the DOM but nobody upgrades them, so CSS can target them with :not(:defined). In the demo, that's the dashed box. Press the button and they upgrade on the spot.

The same idea works without JavaScript creating the shadow root. With declarative shadow DOM, the shadowrootcustomelementregistry attribute on the <template> tells the parser to leave the registry empty. Chrome's article points out that this attribute alone does nothing more: you still have to define the elements and call initialize().

<my-host>
  <template shadowrootmode="open" shadowrootcustomelementregistry>
    <ui-button></ui-button>
  </template>
</my-host>

There's also a version for single elements: document.createElement('ui-button', { customElementRegistry: registryA }). According to Chrome, an element created this way keeps using that registry wherever you insert it later.

Gotchas

  • No fallback to the global registry. I tested this in Chromium 151. In a shadow root with a scoped registry, a tag that only exists globally stays undefined. Each library has to define every element it uses inside its own registry.
  • Feature-detect by constructing, not by checking for the class. In the Firefox and WebKit builds I tested with, CustomElementRegistry exists (it's the type of window.customElements), but new CustomElementRegistry() throws TypeError: Illegal constructor. The WebKit build even has initialize on the prototype. The demo uses this check:
let supportsScoped = false;
try { new CustomElementRegistry(); supportsScoped = true; } catch {}
  • Use a fresh class for each definition. Firefox and WebKit refuse to register the same constructor under two names in the global registry. The demo creates a new class each time it calls define(), so its fallback doesn't break.
  • There's no clean fallback. If a browser doesn't support scoped registries, you're back to renaming tags. That's what the demo does in that case, with a visible warning.

Browser support

According to webstatus.dev, it works in Chrome and Edge 146, Firefox 159 and Safari 26, and it's been Baseline Newly available since 27 October 2026. Firefox 159's release notes also list the customElementRegistry property on Document, Element and ShadowRoot. Because it's so new, anyone who hasn't updated Firefox in the last two weeks will hit the fallback.

What to keep an eye on

  • Libraries adopting it. The feature only pays off once component libraries let you pass a registry in, or create their own scoped registries, instead of calling customElements.define() when they're imported.
  • Firefox stragglers. Firefox 159 is days old. Check your analytics before you remove a prefix-based fallback.
  • Server-rendered components. The declarative shadowrootcustomelementregistry path is the one to watch if you render shadow DOM on the server.

Sources

  • Using custom elements: Scoped custom element registries (MDN)
  • CustomElementRegistry() constructor (MDN)
  • Scoped custom element registries (Chrome for Developers)
  • Firefox 159 release notes for developers (MDN)
  • WebKit features in Safari 26.0 (WebKit blog)

Originally published at richi.codes.

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.