Scoped custom element registriesで同じ名前のカスタム要素を共存させる
Scoped custom element registriesは、グローバルなcustomElementsとは別にカスタム要素のレジストリを作り、シャドウルートに結びつける仕組みです。Safari 26とChrome 146に続いてFirefox 159が対応し、Baseline 2026に加わりました。レジストリの結びつけ方、要素のレジストリが決まるタイミング、宣言的Shadow DOMでの使い方を整理します。
- Chrome146
- Edge146
- Firefox159
- Safari26
はじめに
別々のライブラリが同じタグ名のカスタム要素を使っていたり、同じデザインシステムの新旧の版が1つのページに同居したりすると、後から読み込んだほうのdefine()が失敗します。カスタム要素のレジストリはページ全体でwindow.customElementsの1つしかなく、同じ名前を2回は定義できないからです。
customElements.define('x-badge', BadgeV1);
customElements.define('x-badge', BadgeV2); // NotSupportedErrorScoped custom element registriesは、グローバルなレジストリとは別のCustomElementRegistryを作り、シャドウルートに結びつける仕組みです。複数のライブラリが互いに調整しなくても共存できるように設計されています。Safari 26とChrome 146に続いてFirefox 159が対応し、Baseline 2026に加わりました。
レジストリを作ってシャドウルートに結びつける
レジストリはnew CustomElementRegistry()で作ります。define()・get()・getName()・whenDefined()・upgrade()はcustomElementsと同じように使えます。作ったレジストリをattachShadow()のcustomElementRegistryオプションに渡すと、そのシャドウルートに結びつきます。
次の例では、ページ全体のx-badgeはBadgeV1のまま、scoped-panelの中のx-badgeだけをBadgeV2にしています。
// ページ全体の x-badge は BadgeV1
customElements.define('x-badge', BadgeV1);
// scoped-panel の中の x-badge は BadgeV2
const registry = new CustomElementRegistry();
registry.define('x-badge', BadgeV2);
class ScopedPanel extends HTMLElement {
constructor() {
super();
const root = this.attachShadow({
mode: 'open',
// このシャドウルートの中の要素は、定義を registry からだけ探す
customElementRegistry: registry,
});
root.innerHTML = '<x-badge></x-badge>';
}
}
customElements.define('scoped-panel', ScopedPanel);ページに2つの要素を書くと、それぞれ別の定義になります。
<x-badge></x-badge> <!-- BadgeV1 -->
<scoped-panel></scoped-panel> <!-- 中の x-badge は BadgeV2 -->scoped-panelのシャドウルートの中の要素は、定義をregistryからだけ探し、customElementsは見ません。そのため、中のx-badgeはBadgeV2になり、ページのBadgeV1とは衝突しません。逆にregistryに無い名前は、customElementsに定義があっても未定義のまま残ります。シャドウルートの中をinnerHTMLやsetHTMLUnsafe()で書いたときも、パーサーはこのレジストリで要素を作ります。
オプションを省くと、シャドウルートはこれまでどおりグローバルなレジストリ(customElements)に結びつきます。結びついたレジストリは、shadowRoot.customElementRegistryやelement.customElementRegistryで確かめられます。
要素のレジストリは作るときに決まる
document.createElement('x-badge')で作った要素を、レジストリを結びつけたシャドウルートに追加しても、そのレジストリの定義ではupgradeされません。要素がどのレジストリに属するかは要素を作るときに決まり、createElement()はオプションが無ければグローバルなレジストリの要素を作るからです。customElementsに同じ名前の定義があれば、そちらの定義でupgradeされます。シャドウルートのレジストリに属する要素を作るには、customElementRegistryオプションにレジストリを渡します。
const plain = document.createElement('x-badge');
root.append(plain); // グローバルなレジストリの要素のままで、BadgeV1 になる
const scoped = document.createElement('x-badge', {
customElementRegistry: registry,
});
root.append(scoped); // BadgeV2 になる<template>から複製するときも同じです。HTMLに書いた<template>の中身の要素は、レジストリを持ちません。template.content.cloneNode(true)で複製してシャドウルートに追加すると、追加した時点でグローバルなレジストリが割り当てられます。document.importNode()のcustomElementRegistryオプションを使えば、レジストリを持たない要素に指定したレジストリを付けて複製できます。
// 追加した時点で customElements が割り当てられる
root.append(template.content.cloneNode(true));
// レジストリを持たない要素に registry が付く
root.append(
document.importNode(template.content, { customElementRegistry: registry }),
);宣言的Shadow DOMで使う
宣言的Shadow DOMでHTMLに書いたシャドウルートは、グローバルなレジストリに結びつきます。自分で作ったレジストリを使うには、<template>にshadowrootcustomelementregistry属性を足します。属性があると、シャドウルートのレジストリはnullのままになり、中の要素もupgradeされません。
<div id="panel">
<template shadowrootmode="open" shadowrootcustomelementregistry>
<x-badge></x-badge>
</template>
</div>レジストリを作って定義を登録するところは、attachShadow()のときと同じくスクリプトで書きます。HTMLではレジストリを指定できないので、作ったレジストリのinitialize()にシャドウルートを渡して結びつけます。シャドウルートと、レジストリがnullの子孫にそのレジストリが設定され、定義のある要素がupgradeされます。
const registry = new CustomElementRegistry();
registry.define('x-badge', BadgeV2);
const panel = document.querySelector('#panel');
// シャドウルートと中の x-badge に registry が設定され、x-badge は BadgeV2 になる
registry.initialize(panel.shadowRoot);ほかのAPIはレジストリを受け取る側にオプションがあるのに、ここだけレジストリの側のメソッドです。宣言的Shadow DOMのシャドウルートはパーサーが作るので、作る時点ではスクリプトのレジストリを渡せません。そこで、shadowrootcustomelementregistry属性でシャドウルートのレジストリをnullのまま作っておき、あとからレジストリのinitialize()でnullのところを埋めます。シャドウルートのcustomElementRegistryに代入する形も仕様の議論で挙がりましたが、1回しか代入できないプロパティは珍しい振る舞いだとして採られませんでした。
initialize()が埋めるのは、レジストリがnullのノードだけです。そのため、同じシャドウルートに2つのレジストリでinitialize()を呼ぶと、先に呼んだほうが残ります。
registryA.initialize(panel.shadowRoot); // registryA が設定される
registryB.initialize(panel.shadowRoot); // 何もしない。registryA のまま後から呼んだほうは何もせず、例外も出ません。属性を付け忘れてグローバルなレジストリに結びついたシャドウルートを渡したときも同じです。思ったレジストリになっているかは、shadowRoot.customElementRegistryを読んで確かめます。
おわりに
Scoped custom element registriesを使うと、シャドウルートごとにレジストリを分け、同じ名前のカスタム要素を1つのページで共存させられます。要素のレジストリは作るときに決まるので、スクリプトで要素を足すときはcreateElement()やimportNode()にレジストリを渡します。宣言的Shadow DOMでは、shadowrootcustomelementregistry属性でレジストリを空けておき、あとからinitialize()で結びつけます。