Declarative Shadow DOM: el breakthrough del SSR
El bloqueador más grande para Web Components en producción era el server-side rendering. Shadow DOM era una API puramente del lado del cliente, tenías que llamar a attachShadow() en JavaScript para crear uno. Eso significaba que no había Shadow DOM hasta que JavaScript se cargara, parseara y ejecutara. Para aplicaciones SSR-heavy, era un dealbreaker.
Declarative Shadow DOM (DSD) arregla esto permitiendo que el Shadow DOM se exprese en 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">Card server-rendered</span>
<p>Este shadow DOM existe antes de que corra cualquier JS.</p>
</my-card>
El browser parsea el <template shadowrootmode="open"> y inmediatamente attachea un shadow root, no se necesita JavaScript. El componente renderiza con encapsulamiento de estilos completo en el first paint. Cuando tu clase de componente finalmente carga, connectedCallback se dispara y el shadow root ya existe:
class MyCard extends HTMLElement {
constructor() {
super();
// No llamar attachShadow() si DSD ya creó uno
if (!this.shadowRoot) {
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `/* template fallback */`;
}
}
connectedCallback() {
// El shadow root puede ya tener contenido de DSD
this.#upgradeSlots();
this.#attachEventListeners();
}
#upgradeSlots() {
const titleSlot = this.shadowRoot.querySelector('slot[name="title"]');
titleSlot?.addEventListener('slotchange', () => {
this.#updateAriaLabel();
});
}
}
customElements.define('my-card', MyCard);
El patrón crítico: siempre verificar if (!this.shadowRoot) antes de llamar attachShadow(). Los componentes DSD llegan con un shadow root ya poblado. Llamar attachShadow() de nuevo tira un error.
Renderizando DSD en el servidor
Del lado del servidor (Node.js, Deno, o edge runtime), generar output DSD renderizando tu template como HTML:
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>
`;
}
Esto funciona con cualquier framework SSR, Next.js, Astro, SvelteKit. El HTML llega completamente renderizado. Sin FOUC, sin layout shift, sin esperar JavaScript.
CSS Parts: theming bien hecho
Antes de CSS parts, personalizar estilos de Web Components desde afuera era doloroso. Tenías dos opciones: CSS custom properties (limitadas a valores, no selectores) o penetrar el shadow boundary con ::shadow (deprecado y eliminado).
CSS ::part() le permite a un componente exponer partes nombradas de su shadow DOM para estilos externos:
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">Nombre</th>
<th part="header-cell">Valor</th>
</tr>
</thead>
<tbody part="body"></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('');
}
}
Ahora los consumidores pueden estilar los internos sin tocar el código del componente:
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);
}
La belleza de las parts: el autor del componente controla qué es exponible, y el consumidor controla cómo se ve. La estructura interna del DOM puede cambiar libremente sin romper los estilos del consumidor, siempre y cuando los nombres de las parts se mantengan estables.
Custom elements asociados a formularios
Este era un gap de larga data. Custom elements que wrappeaban form inputs eran invisibles al comportamiento nativo de <form>, sin submission, sin validación, sin integración con FormData. La API ElementInternals arregla esto completamente:
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} estrella${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());
if (this.required && v === 0) {
this.#internals.setValidity(
{ valueMissing: true },
'Por favor seleccioná una calificación',
this.shadowRoot.querySelector('button')
);
} else {
this.#internals.setValidity({});
}
this.shadowRoot.querySelectorAll('button').forEach((btn, i) => {
btn.classList.toggle('active', i < v);
});
}
get required() { return this.hasAttribute('required'); }
formResetCallback() { this.value = 0; }
formStateRestoreCallback(state) { this.value = parseInt(state) || 0; }
}
customElements.define('star-rating', StarRating);
El componente star-rating participa en form submission, validación y reset, igual que un <input> nativo. La pseudo-clase :invalid incluso funciona a través del shadow boundary.
Lit 4.0: el framework que Web Components necesitaba
Lit 4.0 (lanzado a fines de 2025) pulió la experiencia de desarrollo al punto de sentirse competitivo con React o Vue para authoring de componentes:
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;
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 ? 'Menos' : 'Más'}
</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);
La realidad de interop con frameworks
React 19+ finalmente maneja custom elements correctamente. Las propiedades pasan (no solo atributos string), los eventos funcionan, los refs funcionan. Los años de hacks con react-web-component-wrapper terminaron:
function App() {
const handleToggle = (e) => {
console.log('Expandido:', 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 y Svelte tienen buena interop con Web Components hace años. Angular los soporta nativamente a través de CUSTOM_ELEMENTS_SCHEMA. El único punto de dolor restante es la hidratación SSR, si tu framework server-renderiza HTML y un Web Component espera hidratar su output DSD, el timing puede ser tricky. El SSR de Lit maneja esto; otros frameworks pueden necesitar coordinación manual.
Cuándo realmente usarlos
Al examinar design systems construidos tanto con approaches React-only como Web Components, surge una evaluación clara:
Utilizar Web Components cuando: se tiene múltiples equipos con diferentes frameworks consumiendo la misma librería de componentes, se necesita widgets agnósticos de framework (scripts embebidos, micro-frontends), o se está construyendo para longevidad y se quiere evitar el churn de frameworks.
Continuar usando los componentes de tu framework cuando: se es un equipo single-framework, se necesita integración profunda con features específicas del framework (React Server Components, Svelte stores), o tu equipo no quiere aprender un segundo modelo de componentes.
El punto óptimo que emerge es primitivas de design system como Web Components (botones, inputs, cards, modales) consumidas por código de aplicación específico del framework. Las primitivas cambian poco y se benefician de la independencia de framework. La lógica de aplicación cambia constantemente y se beneficia de la ergonomía del framework.
No es una toma revolucionaria, y no debería serlo. Web Components son una herramienta. Resuelven un problema específico, primitivas de UI encapsuladas, reutilizables e interoperables, y en 2026, lo resuelven bien. El ciclo de hype terminó. La fase aburrida de productividad comenzó. Eso es exactamente donde se quiere que esté una tecnología de plataforma.