<!-- Canonical: https://lucioduran.com/es/blog/web-components-shadow-dom-custom-elements-2026
Language: es
Content-SHA256: 2a7b9895108c0d7d81214b965d57acbe97bdebbf451a3a0e6547dcc4e5c98bd5
-->

Frontend

# Web Components en 2026: Declarative Shadow DOM, CSS Parts e Interoperabilidad con Frameworks

2025-10-137 min lectura

Lucio Durán
Engineering Manager & AI Solutions Architect

También disponible en: [English](<https://lucioduran.com/blog/web-components-shadow-dom-custom-elements-2026>)

## 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:

```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:

```javascript
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:

```javascript
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:

```javascript
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:

```css
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:

```javascript
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':''}">
 &#9733;
 </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](<https://lit.dev/>) 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:

```javascript
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:

```jsx
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.

## Preguntas frecuentes

### ¿Los Web Components están listos para producción en 2026?

Sí. Todos los navegadores principales soportan custom elements v1, Shadow DOM, Declarative Shadow DOM, CSS parts, y la API ElementInternals. El ecosistema (Lit 4.0, Stencil, FAST) es maduro. La fricción principal que queda es la hidratación SSR en frameworks que no son Lit.

### ¿Puedo usar Web Components con React?

Sí, React 19+ tiene soporte completo de custom elements incluyendo pasaje de propiedades (no solo atributos), manejo de eventos, y ref forwarding. La historia de interop finalmente es sólida después de años de workarounds.

### ¿Los Web Components reemplazan a React/Vue/Svelte?

No. Los Web Components son una primitiva de la plataforma para elementos de UI encapsulados y reutilizables. Los frameworks proveen state management, routing y arquitectura de aplicación. El mejor caso de uso para Web Components es componentes de design system consumidos por equipos con diferentes frameworks.

## Tips

1. **Tip:** Utilizar ` ::part() ` para theming, no CSS custom properties para todo. Las parts le dan a los consumidores control de estilos específico sin exponer tu estructura interna del DOM.
2. **Tip:** Siempre establecer ` formAssociated = true ` e implementá ` ElementInternals ` si tu componente wrappea un form input. Sin esto, tu componente es invisible para el submission y validación del ` <form> `.
3. **Tip:** Probar con Declarative Shadow DOM SSR corriendo ` getInnerHTML({includeShadowRoots: true}) ` y verificá que el output hidrate correctamente. Muchos componentes se rompen porque asumen que ` connectedCallback ` corre antes de que el shadow DOM exista.

- web-components
- shadow-dom
- custom-elements
- lit
- css-parts
- declarative-shadow-dom
- frontend
- interop

[Volver al Blog](<https://lucioduran.com/es/blog>)
