Appearance
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:
- Create a CSS file with styles scoped under the element name
- Import the CSS file from the JS module
- Replace
this.attachShadow()+this.shadowRoot.appendChild()withthis.appendChild() - Replace
this.shadowRoot.querySelector()withthis.querySelector() - Replace
this.shadowRoot.addEventListener()withthis.addEventListener() - Convert
:hostselectors to the element name - Remove the
<style>block from the template - Verify after each step
See Refactoring for the incremental verification workflow.