Declarative Shadow DOM: the SSR breakthrough
The biggest blocker for Web Components in production was server-side rendering. Shadow DOM was a purely client-side API, you had to call attachShadow() in JavaScript to create one. That meant no Shadow DOM until JavaScript loaded, parsed, and executed. For SSR-heavy applications, this was a dealbreaker.
Declarative Shadow DOM (DSD) fixes this by allowing Shadow DOM to be expressed in HTML:
<my-card>
<template shadowrootmode="open">
<style>
:host {
display: block;
border: 1px solid var(--card-border, #e2e0dc);
border-radius: 8px;
padding: 1rem;
}
.header { font-weight: 600; }
.content { margin-top: 0.5rem; }
</style>
<div class="header">
<slot name="title"></slot>
</div>
<div class="content">
<slot></slot>
</div>
</template>
<span slot="title">Server-rendered card</span>
<p>This shadow DOM exists before any JS runs.</p>
</my-card>
The browser parses the <template shadowrootmode="open"> and immediately attaches a shadow root, no JavaScript needed. The component renders with full style encapsulation on first paint. When your component class eventually loads, connectedCallback fires and the shadow root already exists:
class MyCard extends HTMLElement {
constructor() {
super();
// Don't call attachShadow() if DSD already created one
if (!this.shadowRoot) {
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `/* fallback template */`;
}
}
connectedCallback() {
// Shadow root may already have content from DSD
this.#upgradeSlots();
this.#attachEventListeners();
}
#upgradeSlots() {
const titleSlot = this.shadowRoot.querySelector('slot[name="title"]');
titleSlot?.addEventListener('slotchange', () => {
this.#updateAriaLabel();
});
}
}
customElements.define('my-card', MyCard);
The critical pattern: always check if (!this.shadowRoot) before calling attachShadow(). DSD components arrive with a shadow root already populated. Calling attachShadow() again throws an error.
Server-rendering DSD
On the server side (Node.js, Deno, or edge runtime), generate DSD output by rendering your template as HTML:
// Server-side rendering function
function renderMyCard({ title, content, theme = 'light' }) {
return `
<my-card data-theme="${theme}">
<template shadowrootmode="open">
<style>
:host { display: block; border-radius: 8px; padding: 1rem; }
:host([data-theme="dark"]) { background: #1a1816; color: #faf9f5; }
:host([data-theme="light"]) { background: #faf9f5; color: #1a1816; }
::slotted([slot="title"]) { font-size: 1.25rem; font-weight: 600; }
</style>
<slot name="title"></slot>
<slot></slot>
</template>
<span slot="title">${escapeHtml(title)}</span>
<div>${escapeHtml(content)}</div>
</my-card>
`;
}
This works with any SSR framework, Next.js, Astro, SvelteKit. The HTML arrives fully rendered. No FOUC (flash of unstyled content), no layout shift, no waiting for JavaScript.
CSS Parts: theming done right
Before CSS parts, customizing Web Component styles from outside was painful. You had two options: CSS custom properties (limited to values, not selectors) or piercing the shadow boundary with ::shadow (deprecated and removed).
CSS ::part() lets a component expose named parts of its shadow DOM for external styling:
class DataTable extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<style>
table { width: 100%; border-collapse: collapse; }
th { text-align: left; }
tr:hover { background: var(--table-hover, rgba(0,0,0,0.04)); }
</style>
<table part="table">
<thead part="header">
<tr part="header-row">
<th part="header-cell">Name</th>
<th part="header-cell">Value</th>
</tr>
</thead>
<tbody part="body">
<!-- rows rendered dynamically -->
</tbody>
</table>
`;
}
set data(rows) {
const tbody = this.shadowRoot.querySelector('tbody');
tbody.innerHTML = rows.map(row => `
<tr part="row">
<td part="cell">${row.name}</td>
<td part="cell">${row.value}</td>
</tr>
`).join('');
}
}
Now consumers can style the internals without touching the component code:
/* Consumer styles, outside the shadow DOM */
data-table::part(header) {
background: #d97757;
color: white;
}
data-table::part(header-cell) {
padding: 0.75rem 1rem;
font-weight: 600;
text-transform: uppercase;
font-size: 0.75rem;
letter-spacing: 0.05em;
}
data-table::part(row):hover {
background: rgba(217, 119, 87, 0.08);
}
data-table::part(cell) {
padding: 0.5rem 1rem;
border-bottom: 1px solid #e2e0dc;
}
The beauty of parts: the component author controls what's exposable, and the consumer controls how it looks. The internal DOM structure can change freely without breaking consumer styles, as long as the part names stay stable.
Compound parts with exportparts
When Web Components nest other Web Components, parts don't bubble up by default. Use exportparts to forward inner parts:
<user-profile>
<template shadowrootmode="open">
<data-table exportparts="header: profile-header, cell: profile-cell">
</data-table>
</template>
</user-profile>
<style>
/* Now accessible from outside user-profile */
user-profile::part(profile-header) {
background: navy;
}
</style>
Form-associated custom elements
This was a long-standing gap. Custom elements that wrapped form inputs were invisible to native <form> behavior, no submission, no validation, no FormData integration. The ElementInternals API fixes this completely:
class StarRating extends HTMLElement {
static formAssociated = true;
#internals;
#value = 0;
constructor() {
super();
this.#internals = this.attachInternals();
this.attachShadow({ mode: 'open', delegatesFocus: true });
this.shadowRoot.innerHTML = `
<style>
:host { display: inline-flex; gap: 2px; }
button {
background: none; border: none; cursor: pointer;
font-size: 1.5rem; padding: 0; color: #ccc;
transition: color 0.15s;
}
button.active { color: #d97757; }
button:focus-visible { outline: 2px solid #d97757; border-radius: 2px; }
:host(:invalid) button.active { color: #e74c3c; }
</style>
${[1,2,3,4,5].map(n => `
<button type="button" data-value="${n}" aria-label="${n} star${n>1?'s':''}">
★
</button>
`).join('')}
`;
this.shadowRoot.addEventListener('click', (e) => {
const btn = e.target.closest('button');
if (!btn) return;
this.value = parseInt(btn.dataset.value);
});
}
get value() { return this.#value; }
set value(v) {
this.#value = v;
this.#internals.setFormValue(v.toString());
// Native form validation
if (this.required && v === 0) {
this.#internals.setValidity(
{ valueMissing: true },
'Please select a rating',
this.shadowRoot.querySelector('button')
);
} else {
this.#internals.setValidity({});
}
// Update visual state
this.shadowRoot.querySelectorAll('button').forEach((btn, i) => {
btn.classList.toggle('active', i < v);
});
}
get required() { return this.hasAttribute('required'); }
// Form lifecycle callbacks
formResetCallback() { this.value = 0; }
formStateRestoreCallback(state) { this.value = parseInt(state) || 0; }
}
customElements.define('star-rating', StarRating);
Usage in a form, fully native:
<form id="review-form">
<label>Rating:</label>
<star-rating name="rating" required></star-rating>
<label>Comment:</label>
<textarea name="comment"></textarea>
<button type="submit">Submit</button>
</form>
<script>
document.getElementById('review-form').addEventListener('submit', (e) => {
e.preventDefault();
const data = new FormData(e.target);
console.log(data.get('rating')); // "4"
console.log(data.get('comment')); // "Great product"
});
</script>
The star-rating component participates in form submission, validation, and reset, just like a native <input>. The :invalid pseudo-class even works through the shadow boundary.
Lit 4.0: the framework that Web Components needed
Lit 4.0 (released late 2025) polished the developer experience to the point where it feels competitive with React or Vue for component authoring. Key additions:
import { LitElement, html, css } from 'lit';
import { property, state, query } from 'lit/decorators.js';
class UserCard extends LitElement {
static styles = css`
:host {
display: block;
container-type: inline-size;
}
.card {
display: grid;
gap: 1rem;
padding: 1rem;
border: 1px solid var(--border-color, #e2e0dc);
border-radius: 8px;
}
@container (min-width: 400px) {
.card { grid-template-columns: auto 1fr; }
}
`;
@property({ type: String }) name = '';
@property({ type: String }) role = '';
@property({ type: String, attribute: 'avatar-url' }) avatarUrl = '';
@state() private _expanded = false;
@query('.details') _detailsEl;
render() {
return html`
<div class="card" part="card">
<img src=${this.avatarUrl} alt=${this.name} part="avatar" />
<div>
<h3 part="name">${this.name}</h3>
<p part="role">${this.role}</p>
<button @click=${this.#toggle}>
${this._expanded ? 'Less' : 'More'}
</button>
${this._expanded ? html`
<div class="details" part="details">
<slot></slot>
</div>
` : ''}
</div>
</div>
`;
}
#toggle() {
this._expanded = !this._expanded;
this.dispatchEvent(new CustomEvent('toggle', {
detail: { expanded: this._expanded },
bubbles: true, composed: true
}));
}
}
customElements.define('user-card', UserCard);
Lit's reactive properties, efficient DOM diffing, and SSR support (@lit-labs/ssr) make it the de facto standard for Web Component authoring. The decorators API is clean, the template literal syntax avoids build-step dependencies, and the output is a standard custom element that works in any framework.
The framework interop reality
React 19+ finally handles custom elements properly. Properties pass through (not just string attributes), events work, refs work. The years of react-web-component-wrapper hacks are over:
// React 19+, just works
function App() {
const handleToggle = (e) => {
console.log('Expanded:', e.detail.expanded);
};
return (
<user-card
name="Lucio Durán"
role="Software Engineer"
avatar-url="/avatar.jpg"
onToggle={handleToggle}
>
<p>Buenos Aires, Argentina</p>
</user-card>
);
}
Vue and Svelte have had good Web Component interop for years. Angular supports them natively through CUSTOM_ELEMENTS_SCHEMA. The one remaining pain point is SSR hydration, if your framework server-renders HTML and a Web Component expects to hydrate its DSD output, the timing can be tricky. Lit's SSR handles this; other frameworks may need manual coordination.
When to actually use them
After examining design systems built with both React-only and Web Component approaches, here is an honest assessment:
Use Web Components when: you have multiple framework teams consuming the same component library, you need framework-agnostic widgets (embed scripts, micro-frontends), or you're building for longevity and want to avoid framework churn.
Keep using your framework's components when: you're a single-framework shop, you need deep integration with framework-specific features (React Server Components, Svelte stores), or your team doesn't want to learn a second component model.
The sweet spot that emerges is design system primitives as Web Components (buttons, inputs, cards, modals) consumed by framework-specific application code. The primitives rarely change and benefit from framework independence. The application logic changes constantly and benefits from framework ergonomics.
That's not a revolutionary take, and it shouldn't be. Web Components are a tool. They solve a specific problem, encapsulated, reusable, interoperable UI primitives, and in 2026, they solve it well. The hype cycle is over. The boring productivity phase has begun. That's exactly where you want a platform technology to be.