Why Shadow DOM by default
Most modern frameworks ship light-DOM components and treat Shadow DOM as
opt-in (Lit being the obvious exception). Purity flips that — component()
always attaches a shadow root. Here's the case for that choice, and the
honest costs.
What you actually get
component() calls this.attachShadow({ mode: 'open' }) in
connectedCallback (src/elements.ts).
That means:
- CSS isolation by default. Styles you write inside the component's
css\`` template can only match elements inside that component's shadow tree. No cascade leakage in or out. - DOM encapsulation.
document.querySelectorfrom outside your component can't reach into its shadow root. You query through the host element's.shadowRoot— explicit, intentional access. - Slot semantics for free.
<slot>is a real platform feature inside shadow roots. No userland slot-resolution algorithm to reimplement. - No userland scope-id rewriting.
adoptedStyleSheetsis shared across instances and lives next to the host, not in a global stylesheet.
The shadow mode is open, so consumers can still reach the shadow root
for testing or composition (el.shadowRoot.querySelector(...)). It's a
fence with a gate, not a wall.
When this pays off
- Design systems / component libraries. The whole reason web components exist. Style isolation across teams and apps is the dominant requirement.
- Apps with a strong "components are black boxes" architecture. Refactoring a component's internal markup never breaks a consumer.
- Embedding inside hosts you don't control. Widgets, embeds, dashboards inside CMS or marketing-tool iframes. Global styles can't crash you.
- Theming via CSS variables. Custom properties pierce shadow boundaries,
so design tokens declared on
:rootwork everywhere, but utility classes do not. This is usually what you want.
When this hurts
Honestly, every case below is a real pain point. Shadow DOM isn't a free win.
Tailwind / utility CSS frameworks
Tailwind generates one big stylesheet that you load in <head>. Those
classes do not apply inside a shadow root.
Workarounds, ranked least to most painful:
- Don't use Tailwind inside
component()(recommended). Style components via thecsstemplate; reserve Tailwind for the light-DOM shell that mounts them. This is whatcomponent()is best at. - Use light-DOM components instead.
mount(componentFn, container)does not create a shadow root, so global Tailwind classes apply normally. You lose Shadow-DOM-style scoped CSS, but if Tailwind is the dominant styling story, this is the right tradeoff. - Inject Tailwind via
adoptedStyleSheetsper shadow root. Possible in principle (new CSSStyleSheet()+replaceSync+root.adoptedStyleSheets = [...]), but Purity does not yet expose ahost()accessor insidecomponent(), so per-instance injection from the render function is awkward today. The straightforward path is to walkdocument.querySelectorAll('p-…')and inject at module load (plus aMutationObserverfor dynamically added instances). First-class host access is post-1.0.
Form libraries / native form participation
A <input> inside a shadow root is not a form-associated element from
the host form's perspective. Native form submission won't include its
value; React Hook Form (etc.) won't see it via DOM walking.
For a component with one native input, select, or textarea, pass
{ formControl: true } to component(). Purity uses
ElementInternals
to submit its value and mirror validity, reset, and disabled state. The
manual { formAssociated: true } option remains available for controls
that manage their own ElementInternals state. The internal control stays
inside Shadow DOM, so libraries that only walk light DOM cannot inspect it.
Accessibility across shadow boundaries
An aria-labelledby ID on a control inside a shadow root cannot reference
an element in light DOM, or vice versa. An ARIA attribute on the custom
element host can reference another light-DOM element. That host label does
not automatically name a control inside the shadow root.
See accessibility.md for the patterns.
Third-party scripts that walk the DOM
Analytics tools, screen scrapers, and some RUM libraries query the document tree. They don't traverse shadow roots by default.
Fix: publish a stable selector via the host element. E.g. add
data-purity-component="card" on the host, and document that
third-party tools should query hosts and dive in via .shadowRoot.
Server-side rendering
Declarative Shadow DOM
is supported by the separate @purityjs/ssr
package. It renders registered components on the server, and the core
hydrate() API adopts compatible server-rendered DOM on the client. The core
package itself remains client-side; install the SSR package when server
rendering is part of the app.
Platform name collisions
Custom-element tag names must contain a hyphen and avoid reserved names
(font-face, annotation-xml, color-profile, etc.). The convention
in Purity examples is the p- prefix — pick something distinctive for
your app to avoid clashes with third-party widgets you may load later.
Escape hatches
If a specific component needs to opt out, the cleanest options are:
-
Render via
mount(componentFn, container)instead ofcomponent(). Samestate/compute/watch/html/resource, no shadow root. You lose Shadow-DOM-style scoped CSS, but everything else works. -
Reset host CSS inheritance. Inside the shadow tree:
css` :host { all: initial; } :host > * { /* explicit per-element resets */ } `;Useful when you need a "clean slate" inside a host page that has aggressive global styles.
-
Read styles back through the host.
getComputedStyle(host)from outside, or pierce explicitly withhost.shadowRoot.querySelector(...)when you genuinely need to.
Summary
Shadow DOM by default is opinionated. The opinion is "encapsulation is
worth a small set of well-known frictions." If those frictions disqualify
your use case (typically: heavy Tailwind usage, deep form-library
integration, SEO-critical content rendering), use mount + html and
skip component(). The framework's reactivity primitives don't depend
on Custom Elements at all.