Web components without Shadow DOM
GUIDECustom elements and Shadow DOM are two separate specifications that are almost always introduced together. You can use the first without the second, and for a component library with a single owner it is often the better trade. Here is the honest ledger.
A custom element is a class registered against a tag name. Shadow DOM is a separate mechanism for attaching an encapsulated subtree to an element. Nothing in the custom-elements specification requires the second, and customElements.define() works exactly the same either way.
class Card extends HTMLElement {
connectedCallback() {
// No attachShadow(). Children land in the ordinary document tree,
// reachable by ordinary selectors.
this.innerHTML = '<div class="card__body"></div>';
}
}
customElements.define('x-card', Card);
What Shadow DOM genuinely gives you
The case for it is real and worth stating properly before arguing against it.
- Style isolation in both directions. Page CSS cannot reach in, component CSS cannot leak out. No naming convention required, no specificity war to lose.
- DOM privacy.
querySelectorfrom the page does not traverse into an open shadow root by default, so a consumer cannot casually depend on your internal structure and break when you change it. - Scoped ids and slots. An
idinside a shadow root cannot collide with the page, and slot-based composition is genuinely elegant.
If you are shipping a widget that will be embedded in pages you do not control — an analytics badge, a payment button, an embeddable chat — take the encapsulation. Those costs below are worth paying when the alternative is a stranger's * { box-sizing: content-box } destroying your layout.
What it costs
Cross-root ARIA is still awkward
This is the sharpest cost and the least discussed. ARIA relationships are expressed with IDREFs — aria-labelledby, aria-describedby, aria-controls, aria-activedescendant, the for attribute — and an IDREF does not cross a shadow boundary. A label in the light DOM cannot point at an input inside a shadow root by id.
The platform has been closing this gap: ElementInternals exposes ARIA reflection so a component can set its own role and state from inside, and the Reference Target proposal aims to let a shadow root nominate an element as the target of outside IDREFs. But reflection only covers the element's own semantics, not relationships you want a consumer to author, and the remaining gaps are exactly the composite widgets — comboboxes, grids, tab sets — where accessibility is hardest.
In light DOM this problem does not exist. A <label for> finds its input because they are in the same tree, which is the whole mechanism.
Theming becomes an API surface you have to design
Encapsulation blocks page CSS, so every property a consumer might want to change must be deliberately exposed — as a CSS custom property (which does pierce the boundary, being inherited), or via ::part(). Both work. Both mean that "can I change the border radius?" is a feature request against your library rather than a line of CSS.
For a design system with one owner and known consumers, that gate is pure overhead. The consumers are colleagues, the deviations are legitimate, and the answer is nearly always yes.
Server rendering and first paint
Declarative Shadow DOM makes shadow roots server-renderable, and it is well supported now — but it is a second serialisation format to produce, and per-component styles inside a shadow root are not deduplicated across instances unless you reach for adopted stylesheets. Light DOM has none of this: markup is markup, one stylesheet is one stylesheet, and a page that never runs its JavaScript still shows content.
That last point is decisive for anything that has to be readable without scripting, and it is why the pages of this site emit plain markup for text content and reserve custom elements for the parts where behaviour is the point.
Debugging and tooling friction
document.querySelectorAll()does not see into shadow roots, so ad-hoc console work, test selectors and third-party tooling all need shadow-aware traversal.- Browser find-in-page and text selection across boundaries have historically been inconsistent.
- Print stylesheets and PDF pipelines frequently ignore what they cannot select.
The light-DOM approach in practice
Dropping encapsulation means you owe the codebase a discipline in its place. Three rules cover it.
One namespace, applied without exception. Every class the library emits is prefixed. Collisions become a convention problem, which is a solved one, rather than a runtime accident.
<!-- What <e-card> renders. Every class is ink-prefixed and documented;
the tag itself is the styling hook for the outer box. -->
<e-card>
<div class="ink-card__header">
<h3 class="ink-card__title">Panel status</h3>
</div>
<div class="ink-card__body">…</div>
</e-card>
Theme through custom properties, not overrides. A documented token layer means a consumer restyles the library by setting variables, not by writing more specific selectors. The escape hatch stays available, but it is not the intended path.
/* A panel vendor rebrands the whole library without touching JavaScript. */
.ink-page {
--ink-fg: #1a1a1a;
--ink-bg: #f4f2ec;
--ink-border-width: 3px;
--ink-text-body: 18px;
}
Own the ids you mint. Without shadow scoping, any id a component generates is global. Generate them from a counter with a namespace prefix and never hardcode one.
Choosing
| Situation | Shadow DOM | Light DOM |
|---|---|---|
| Embedded in pages you do not control | Yes | Risky |
| Design system, one owner, known consumers | Optional | Usually better |
| Heavy composite ARIA (combobox, grid, tabs) | Painful | Straightforward |
| Consumers expect to restyle freely | Friction | Natural |
| Must render without JavaScript | Needs DSD | Free |
| Third-party CSS is hostile | Protected | Exposed |
The one thing worth avoiding is deciding by default. Attaching a shadow root because every tutorial does is how a project inherits the costs above without ever having wanted the benefit.