Skip to content

Encapsulation

Custom elements can render into light DOM or shadow DOM. This choice determines whether global styles reach the component's internals, whether the component's styles leak out, and how you query and test its elements.

When to use shadow DOM

Use shadow DOM when the component will be consumed by code you don't control:

  • A widget embedded on third-party pages
  • A design system package shipped to multiple unrelated apps
  • A component that must defend its internals from unknown stylesheets

Shadow DOM guarantees that outside CSS can't break the component and component CSS can't break the page.

When to use light DOM

Use light DOM for everything else — which is most application code:

  • Single codebase with a unified design system
  • Components that should inherit global styles (resets, typography, focus rings)
  • Pages where you want one stylesheet, one cascade, one set of selectors

Light DOM is the default for custom elements. You opt into shadow DOM, not out of it.

The pattern

Light DOM custom element

javascript
import "./my-component.css";

class MyComponent extends HTMLElement {
  constructor() {
    super();
    const template = document.getElementById(
      "my-component-template",
    );
    this.appendChild(
      template.content.cloneNode(true),
    );
    this.#title = this.querySelector(".title");
  }
}

Styles live in a CSS file imported by the JS module. Scope selectors with the element name to avoid collisions:

css
my-component {
  display: flex;
  flex-direction: column;
}

my-component .title {
  font-size: var(--text-lg);
}

Shadow DOM custom element

javascript
class MyWidget extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: "open" });
    this.shadowRoot.appendChild(
      template.content.cloneNode(true),
    );
    this.#title =
      this.shadowRoot.querySelector(".title");
  }
}

Styles go inside the template's <style> block. Use :host instead of the element name. Global styles do not apply inside the shadow root.

Consequences of light DOM

Global styles apply

The global reset, typography, and :focus-visible rules reach every element inside the component. This is usually what you want — one focus ring style, one box-sizing rule, one type scale.

Watch for box-sizing

If a global reset sets * { box-sizing: border-box }, elements that previously lived in shadow DOM (where the reset didn't reach) may change size. Any element with a border or padding and an explicit height or width is affected.

Audit these elements when converting from shadow DOM. Fix by either adjusting dimensions to account for border-box or adding box-sizing: content-box to preserve the original layout.

Selectors are global

Class names like .grid or .status could match elements in other components. Scope every selector with the element name:

css
/* Too broad — matches any .grid on the page */
.grid {
  display: grid;
}

/* Scoped — only matches inside this component */
my-component .grid {
  display: grid;
}

No slots

Light DOM has no <slot> projection. Children are regular DOM nodes — put them where you want them directly, or move them in connectedCallback.

Converting shadow DOM to light DOM

Work outside-in. Convert outer components before inner ones — an inner component can't receive global styles if its parent still encapsulates them.

For each component:

  1. Create a CSS file with styles scoped under the element name
  2. Import the CSS file from the JS module
  3. Replace this.attachShadow() + this.shadowRoot.appendChild() with this.appendChild()
  4. Replace this.shadowRoot.querySelector() with this.querySelector()
  5. Replace this.shadowRoot.addEventListener() with this.addEventListener()
  6. Convert :host selectors to the element name
  7. Remove the <style> block from the template
  8. Verify after each step

See Refactoring for the incremental verification workflow.