v146 · Web Components · Shadow DOM Scoping
Shadow DOM Scoping
A walk-through of how tag name resolution works when a shadow root has a scoped registry. The key rule: elements inside a shadow root resolve custom tag names against the shadow's registry first; the global window.customElements is only consulted as a fallback.
This page explains the scoping rules conceptually. See the Registry Isolation demo for a live interactive example.
how tag name resolution works
-
1
Create a new registry and associate it with a shadow root.
new CustomElementRegistry()creates an empty registry. You pass it toattachShadow({ registry })— the shadow root "owns" that registry for all elements parsed or created inside it.const registry = new CustomElementRegistry(); const shadow = host.attachShadow({ mode: 'open', registry }); -
2
Define elements in the scoped registry, not
window.customElements. Callregistry.define()(notcustomElements.define()). The definition is local to this registry only.registry.define('my-button', class extends HTMLElement { connectedCallback() { this.textContent = 'Library A'; } }); -
3
Insert HTML into the shadow root. When the parser encounters
<my-button>, it looks upmy-buttonin the shadow's registry first. If found, it upgrades the element to the scoped class.shadow.innerHTML = '<my-button></my-button>'; // → upgraded using the scoped registry's definition -
4
The global registry is never consulted for elements inside the scoped shadow. Even if
window.customElements.define('my-button', ...)was called elsewhere, elements inside a shadow with a scoped registry use only the scoped definition. -
5
Elements outside the shadow use the global registry as always.
<my-button>in the main document resolves towindow.customElements.get('my-button')— the scoped registry is invisible to the light DOM.
scope diagram
window.customElements (global)
└─ defines: <app-root> → AppRoot
└─ defines: <nav-bar> → NavBar
// my-button NOT defined here
CustomElementRegistry A (owned by shadow-root-A)
└─ defines: <my-button> → LibraryA.Button
shadow-root-A (host: #lib-a-host)
└─ <my-button> → resolves via Registry A → LibraryA.Button ✓
CustomElementRegistry B (owned by shadow-root-B)
└─ defines: <my-button> → LibraryB.Button
shadow-root-B (host: #lib-b-host)
└─ <my-button> → resolves via Registry B → LibraryB.Button ✓
light DOM
└─ <my-button> → resolves via window.customElements
→ undefined (not registered globally)
common questions
Can a scoped element extend a globally registered element? ▸
Yes. You can extend a class that is also registered globally. The scoped registry only controls which tag name → class mapping is used inside the shadow root — it doesn't prevent inheritance between classes.
What happens if neither the scoped nor global registry has the tag? ▸
The element remains an unknown element (
HTMLElement subtype). It is still created and appended — just not upgraded to a custom class. This is the same behavior as any unknown tag in the light DOM.Can you share a registry between multiple shadow roots? ▸
Yes. You can pass the same
CustomElementRegistry instance to multiple attachShadow({ registry }) calls. All those shadow roots then share the same definitions. This is useful for a component library that wants a single isolated namespace across multiple host elements.Does
document.createElement('my-button') use a scoped registry? ▸No.
document.createElement() always uses the global registry. Elements created this way and then inserted into a scoped shadow root do not automatically get upgraded by the scoped registry.Does this replace DOMPurify / sanitization? ▸
No. Scoped registries solve naming conflicts, not security. The Sanitizer API (also in Chrome 146) handles safe HTML insertion. These are complementary features.
complete example
// --- Shared setup ---
const reg1 = new CustomElementRegistry();
const reg2 = new CustomElementRegistry();
// --- Library A defines its button ---
reg1.define('x-button', class extends HTMLElement {
connectedCallback() {
this.style.background = '#4a90d9';
this.style.color = 'white';
this.style.padding = '0.4rem 1rem';
this.style.display = 'inline-block';
this.textContent = this.textContent || 'Library A';
}
});
// --- Library B defines its button --- same tag, different impl
reg2.define('x-button', class extends HTMLElement {
connectedCallback() {
this.style.background = '#e74c3c';
this.style.color = 'white';
this.style.padding = '0.4rem 1rem';
this.style.display = 'inline-block';
this.textContent = this.textContent || 'Library B';
}
});
// --- Mount both ---
function mount(registry, label) {
const host = document.createElement('div');
document.body.appendChild(host);
const shadow = host.attachShadow({ mode: 'open', registry });
shadow.innerHTML = `<x-button>${label}</x-button>`;
return { host, shadow };
}
const { shadow: shadowA } = mount(reg1, 'Library A Button');
const { shadow: shadowB } = mount(reg2, 'Library B Button');
// Both shadows render x-button — each using their own scoped definition.
// The global registry remains clean:
console.log(customElements.get('x-button')); // undefined
see also
- Registry Isolation Demo — live interactive demo
- ChromeStatus entry
- MDN — CustomElementRegistry
- WICG proposal