DEV Community

JKC
JKC

Posted on Edited on

Stop Prefixing Every Custom Element Name. Scoped Registries Are Baseline

TL;DR: new CustomElementRegistry() gives a shadow root its own private set of custom element definitions, so two libraries can both own a <ui-button> on the same page. Firefox 159 shipped it on October 27, 2026, which makes scoped custom element registries Baseline Newly available.

The name nobody can share

Custom elements have always lived in one global phone book: window.customElements. Every tag name is registered once, for the whole page, forever.

So your app defines <ui-button>. Then you install a date picker that also ships a <ui-button> inside it. One of them loses:

customElements.define("ui-button", AppButton);
customElements.define("ui-button", PickerButton);
// NotSupportedError: Failed to execute 'define' on 'CustomElementRegistry':
// the name "ui-button" has already been used with this registry
Enter fullscreen mode Exit fullscreen mode

That is the exact message Chrome 156 threw when I ran it. The usual fixes are prefixes (<acme-ui-button>), version suffixes (<ui-button-v2>), or a build step that renames tags. They work. They also turn every tag name into a small legal contract.

What it is

A scoped registry is a CustomElementRegistry you create yourself with new. Definitions added to it with define() are not global. They only apply to nodes associated with that registry.

You associate a registry with a subtree in three ways:

  • pass it to attachShadow() as customElementRegistry,
  • call registry.initialize(shadowRoot) on a shadow root that has no registry yet,
  • pass it to document.createElement() as the customElementRegistry option.

Everything outside that subtree keeps using the global customElements, exactly as before.

The minimal one: two buttons, one name

const vendorA = new CustomElementRegistry();
const vendorB = new CustomElementRegistry();

vendorA.define("ui-button", class extends HTMLElement {
  connectedCallback() { this.textContent = "A"; }
});
vendorB.define("ui-button", class extends HTMLElement {
  connectedCallback() { this.textContent = "B"; }
});

const hostA = document.createElement("div");
const hostB = document.createElement("div");
document.body.append(hostA, hostB);

const rootA = hostA.attachShadow({ mode: "open", customElementRegistry: vendorA });
const rootB = hostB.attachShadow({ mode: "open", customElementRegistry: vendorB });

rootA.innerHTML = "<ui-button></ui-button>";
rootB.innerHTML = "<ui-button></ui-button>";
Enter fullscreen mode Exit fullscreen mode

In Chrome 156 the first button rendered A and the second rendered B. Same tag, two definitions, no error. rootA.customElementRegistry === vendorA was true, and the global registry did not learn about either class.

The realistic one: a component that keeps its internals to itself

This is the case that actually hurts: a library component that uses helper elements inside its own shadow DOM, on a page that already has its own elements with the same names.

// The app's own button, registered globally as always.
customElements.define("ui-button", class extends HTMLElement {
  connectedCallback() { this.dataset.from = "app"; }
});

// Inside the date picker library.
const pickerRegistry = new CustomElementRegistry();

pickerRegistry.define("ui-button", class extends HTMLElement {
  connectedCallback() {
    this.dataset.from = "picker";
    this.setAttribute("role", "button");
  }
});

customElements.define("date-picker", class extends HTMLElement {
  constructor() {
    super();
    const root = this.attachShadow({
      mode: "open",
      customElementRegistry: pickerRegistry,
    });
    root.innerHTML = `
      <ui-button>Prev</ui-button>
      <span>Nov 2026</span>
      <ui-button>Next</ui-button>`;
  }
});
Enter fullscreen mode Exit fullscreen mode
<ui-button>Save</ui-button>
<date-picker></date-picker>
Enter fullscreen mode Exit fullscreen mode

I ran this on a clean page in Chrome 156. The page's Save button got data-from="app". Both buttons inside the picker got data-from="picker" and role="button". The library never touched the global ui-button, and the app never had to know the picker had one.

Note that <date-picker> itself is still global. Scoping hides a component's internals; the outer tag you put in your HTML still needs a unique name.

Can I use it?

Where Status
Baseline Newly available since October 27, 2026
Safari (macOS and iOS) 26
Chrome and Edge 146
Firefox 159
Tested Chrome 156, where every result in this post was produced
Spec HTML Standard (CustomElementRegistry)

Safari 26.0 shipped it first, and Safari 26.4 added upgrading elements in initialize() plus fixes around null registries. If you support Safari 26.0 to 26.3, test the initialize() path there before relying on it.

For older browsers, "initialize" in CustomElementRegistry.prototype is a cheap feature check.

Gotchas

  1. document.createElement() still uses the global registry by default. Appending an element does not move it to the new registry; it keeps the one it was created with. I created a ui-button with plain document.createElement("ui-button") and appended it into the picker's shadow root. It came out with data-from="app". Pass { customElementRegistry: root.customElementRegistry } as the second argument, or create the markup through the shadow root's innerHTML, and it came out picker.

  2. initialize() is first come, first served. I called one.initialize(root) and then two.initialize(root) on the same root. Chrome did not throw, and the root kept one. Decide which code owns a shadow root's registry and set it once.

  3. Declarative shadow DOM needs an opt-in and a follow-up. Server-rendered shadow roots get the global registry unless the template says otherwise:

<my-host>
  <template shadowrootmode="open" shadowrootcustomelementregistry>
    <ui-button></ui-button>
  </template>
</my-host>
Enter fullscreen mode Exit fullscreen mode

With the attribute, shadowRoot.customElementRegistry was null and the button did not match :defined. After registry.initialize(host.shadowRoot), it upgraded and matched :defined. Without the attribute, the root used the global customElements.

Wrap-up

Prefixes were a social workaround for a technical limit, and the limit is gone. Pick one component that ships its own helper elements, give its shadow root a registry, and drop a prefix.

Sources

This article was written with the help of AI and checked against the sources above.

Top comments (0)