Web components without Shadow DOM

GUIDE

Custom 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. querySelector from 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 id inside 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

SituationShadow DOMLight DOM
Embedded in pages you do not controlYesRisky
Design system, one owner, known consumersOptionalUsually better
Heavy composite ARIA (combobox, grid, tabs)PainfulStraightforward
Consumers expect to restyle freelyFrictionNatural
Must render without JavaScriptNeeds DSDFree
Third-party CSS is hostileProtectedExposed

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.