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. 1
    Create a new registry and associate it with a shadow root. new CustomElementRegistry() creates an empty registry. You pass it to attachShadow({ 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. 2
    Define elements in the scoped registry, not window.customElements. Call registry.define() (not customElements.define()). The definition is local to this registry only.
    registry.define('my-button', class extends HTMLElement {
      connectedCallback() { this.textContent = 'Library A'; }
    });
  3. 3
    Insert HTML into the shadow root. When the parser encounters <my-button>, it looks up my-button in 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. 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. 5
    Elements outside the shadow use the global registry as always. <my-button> in the main document resolves to window.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.customElementsundefined (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