Web Components — Custom Elements, Shadow DOM y Templates
Guía práctica de Web Components: crear elementos personalizables reutilizables, encapsular estilos con Shadow DOM y componer con templates HTML.
Visión General
Web Components son un conjunto de APIs nativas del navegador para crear elementos HTML reutilizables y encapsulados. Funcionan en cualquier framework — o sin ninguno — y proporcionan verdadera encapsulación de estilos y DOM mediante Shadow DOM. A continuación: las tres tecnologías principales: Custom Elements, Shadow DOM y HTML Templates, con ejemplos prácticos que puedes usar hoy.
Cuándo Usar
- Necesitas elementos UI reutilizables compartidos entre diferentes proyectos o frameworks
- Quieres encapsulación de estilos sin CSS-in-JS o convenciones de nomenclatura BEM
- Estás construyendo un sistema de diseño que debe funcionar en React, Vue, Angular o JS vanilla
- Necesitas extender elementos HTML nativos con comportamiento personalizado
- Quieres componentes independientes del framework para mantenibilidad a largo plazo
Los Web Components también brillan en arquitecturas de tipo islas, donde pequeñas unidades interactivas se hidratan de forma independiente — el patrón de arquitectura de islas encaja de forma natural con custom elements como unidad de encapsulación.
Cuándo NO Usar
- Toda la app vive en un solo framework y se entrega como un único bundle — los componentes del framework son más simples, con mejor gestión de estado e integración con TypeScript
- Necesitas accesibilidad rica gratis — un
<select>o<dialog>reimplementado como custom element pierde el comportamiento nativo de teclado y lectores de pantalla que luego tendrás que reconstruir - El componente debe renderizarse antes de que corra JavaScript — los custom elements autónomos necesitan JS para upgradearse, salvo que uses Shadow DOM declarativo (renderizado en servidor)
- Contenido crítico para SEO vive dentro del shadow root — los buscadores manejan el shadow DOM de forma inconsistente, así que mantén el contenido indexable en el light DOM o usa slots
El trade-off honesto: los Web Components te dan portabilidad y longevidad (la API es la plataforma; no se deprecará el año que viene), a costa de hacer más fontanería tú mismo. Si tu equipo ya comparte un design system en React, envolverlo en custom elements añade una frontera sin comprar interoperabilidad.
Las Tres Tecnologías
| Tecnología | Propósito | Estándar |
|---|---|---|
| Custom Elements | Definir nuevas etiquetas HTML con JavaScript | Custom Elements v1 |
| Shadow DOM | Encapsular DOM y estilos dentro de un componente | Shadow DOM v1 |
| HTML Templates | Declarar fragmentos de marcado reutilizables | HTML Template Element |
Custom Elements
Custom Elements Autónomos
Crea etiquetas HTML completamente nuevas.
class UserCard extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.render();
}
render() {
const name = this.getAttribute('name') || 'Anónimo';
const role = this.getAttribute('role') || 'Usuario';
this.shadowRoot.innerHTML = `
<style>
:host { display: block; padding: 1rem; border: 1px solid #e5e7eb; border-radius: 0.5rem; }
.name { font-weight: 600; color: #111827; }
.role { color: #6b7280; font-size: 0.875rem; }
</style>
<div class="name">${name}</div>
<div class="role">${role}</div>
`;
}
static get observedAttributes() {
return ['name', 'role'];
}
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue !== newValue) this.render();
}
}
customElements.define('user-card', UserCard);
<!-- Uso -->
<user-card name="Alice Chen" role="Senior Engineer"></user-card>
<user-card name="Bob Smith" role="Product Manager"></user-card>
Elementos Built-in Personalizados
Extiende elementos HTML existentes con nuevo comportamiento.
class ConfirmButton extends HTMLButtonElement {
constructor() {
super();
this.addEventListener('click', (e) => {
if (!confirm(this.getAttribute('confirm-message') || 'Estás seguro?')) {
e.preventDefault();
}
});
}
}
customElements.define('confirm-button', ConfirmButton, { extends: 'button' });
<!-- Uso mediante atributo is="" -->
<button is="confirm-button" confirm-message="Eliminar este archivo permanentemente?">
Eliminar
</button>
Atributos vs Properties
Los atributos son strings en el marcado; las properties son valores JavaScript en la instancia del elemento. Los consumidores usarán ambos, así que un componente bien educado los mantiene sincronizados — el patrón se llama reflecting:
class ToggleSwitch extends HTMLElement {
static get observedAttributes() {
return ['checked'];
}
// API de property — acepta valores JS reales
get checked() {
return this.hasAttribute('checked');
}
set checked(value) {
if (value) this.setAttribute('checked', '');
else this.removeAttribute('checked');
}
attributeChangedCallback() {
this.shadowRoot.querySelector('input').checked = this.checked;
this.shadowRoot.host.setAttribute('aria-checked', String(this.checked));
}
}
Refleja estado hacia atributos solo cuando tiene sentido en el marcado (selecciona bien en CSS y aparece en DevTools). Los datos ricos — objetos, callbacks, listas — pertenecen solo a las properties; meter JSON en atributos es una trampa de depuración.
Shadow DOM
Encapsulación
Shadow DOM aísla el DOM y CSS de un componente del resto de la página.
class StyledCounter extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
/* Con alcance solo a este componente */
button {
background: #3b82f6;
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 0.25rem;
cursor: pointer;
}
button:hover { background: #2563eb; }
span { margin-left: 0.5rem; font-weight: 600; }
</style>
<button id="inc">+</button>
<span id="count">0</span>
`;
this.count = 0;
shadow.getElementById('inc').addEventListener('click', () => {
this.count++;
shadow.getElementById('count').textContent = this.count;
});
}
}
customElements.define('styled-counter', StyledCounter);
Slots
Los slots permiten inyectar contenido en el Shadow DOM de un componente.
class AlertBox extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' }).innerHTML = `
<style>
:host { display: block; padding: 1rem; border-radius: 0.5rem; }
:host([type="error"]) { background: #fef2f2; border: 1px solid #fca5a5; }
:host([type="warning"]) { background: #fffbeb; border: 1px solid #fcd34d; }
:host([type="success"]) { background: #f0fdf4; border: 1px solid #86efac; }
::slotted(h3) { margin: 0 0 0.5rem; font-size: 1rem; }
::slotted(p) { margin: 0; color: #4b5563; }
</style>
<slot name="title"></slot>
<slot></slot>
`;
}
}
customElements.define('alert-box', AlertBox);
<!-- Uso con slots nombrados -->
<alert-box type="error">
<h3 slot="title">Conexión Fallida</h3>
<p>No se puede alcanzar el servidor. Por favor verifica tu red e intenta de nuevo.</p>
</alert-box>
Shadow DOM Declarativo
Adjunta un shadow root sin JavaScript colocando un <template shadowrootmode> dentro del HTML del elemento. El navegador lo convierte en un shadow root real antes de que siquiera corra la clase del elemento — lo que significa que el marcado ya está ahí para SSR, fallbacks sin JS y un first paint más rápido:
<alert-box type="error">
<template shadowrootmode="open">
<style>
:host { display: block; padding: 1rem; }
::slotted(h3) { margin: 0 0 0.5rem; }
</style>
<slot name="title"></slot>
<slot></slot>
</template>
<h3 slot="title">Conexión Fallida</h3>
<p>No se puede alcanzar el servidor.</p>
</alert-box>
El Shadow DOM declarativo está soportado en Chrome/Edge 111+, Safari 16.4+ y Firefox 123+. En tu clase, comprueba this.shadowRoot antes de llamar a attachShadow — el root ya existe cuando el elemento se definió declarativamente.
HTML Templates
Los templates declaran marcado reutilizable que no se renderiza hasta clonarse.
<template id="user-row-template">
<tr>
<td class="name"></td>
<td class="email"></td>
<td><button class="delete">Remover</button></td>
</tr>
</template>
function createUserRow(user) {
const template = document.getElementById('user-row-template');
const clone = template.content.cloneNode(true);
clone.querySelector('.name').textContent = user.name;
clone.querySelector('.email').textContent = user.email;
clone.querySelector('.delete').addEventListener('click', () => removeUser(user.id));
return clone;
}
// Agregar a la tabla
document.querySelector('#users tbody').appendChild(createUserRow({
name: 'Alice Chen',
email: 'alice@example.com'
}));
Comunicación entre Componentes
Los atributos pasan datos hacia abajo; los custom events los suben. El detalle que se le escapa a casi todo el mundo: un evento despachado dentro de un shadow root se detiene en la frontera del shadow salvo que actives composed: true.
class UserCard extends HTMLElement {
select() {
this.dispatchEvent(new CustomEvent('user-selected', {
detail: { userId: this.getAttribute('user-id') },
bubbles: true, // permite que suba por el árbol
composed: true // OBLIGATORIO para salir del shadow root
}));
}
}
// Cualquier ancestro — fuera del shadow DOM — puede escuchar:
document.addEventListener('user-selected', (e) => {
console.log('Usuario seleccionado:', e.detail.userId);
});
La convención es eventos DOM para comunicación hacia arriba y atributos/properties para el resto. Evita un event bus global para las interioridades del componente — filtra la superficie de su API a toda la app.
Custom Elements Asociados a Formularios
Un custom element normal no puede participar en un <form> — no envía valor ni valida. ElementInternals lo arregla. Actívalo con static formAssociated = true y luego usa attachInternals() para definir el valor y la validez que ve el formulario:
class RatingInput extends HTMLElement {
static formAssociated = true;
constructor() {
super();
this.internals = this.attachInternals();
this.attachShadow({ mode: 'open' }).innerHTML = `
<div id="stars">★★★★★</div>
`;
this.value = 0;
}
connectedCallback() {
const stars = [...this.shadowRoot.querySelector('#stars').childNodes];
this.shadowRoot.getElementById('stars').addEventListener('click', (e) => {
this.value = Math.ceil(e.offsetX / (e.target.offsetWidth / 5));
this.internals.setFormValue(String(this.value));
this.internals.setValidity(
this.value > 0 ? {} : { customError: true },
'Elige una puntuación'
);
});
}
}
customElements.define('rating-input', RatingInput);
<form>
<rating-input name="rating" required></rating-input>
<button type="submit">Enviar</button>
</form>
Ahora <rating-input> se comporta como un control nativo: su valor viaja con el submit del formulario, form.reportValidity() muestra tu mensaje, y form.reset() llama al hook formResetCallback() si lo implementas.
Theming con CSS Custom Properties
El Shadow DOM bloquea los estilos externos — pero las custom properties atraviesan la frontera. Esa es la API de theming prevista: expón un pequeño conjunto de variables y deja que los consumidores las sobrescriban desde fuera.
// dentro de los estilos shadow del componente
`
:host {
display: block;
padding: var(--card-padding, 1rem);
border: 1px solid var(--card-border, #e5e7eb);
border-radius: 0.5rem;
}
.title { color: var(--card-title-color, #111827); }
`
/* página consumidora — se aplica a través de la frontera shadow */
user-card {
--card-title-color: #1d4ed8;
}
.dark-section user-card {
--card-title-color: #e5e7eb;
--card-border: #374151;
}
Publica los nombres de las variables como parte de la API del componente, igual que documentarías props. Resiste la tentación de exponer ::part() para todo — las variables escalan mejor para sistemas de design tokens.
Ciclo de Vida del Componente
| Callback | Cuándo Se Dispara | Uso Común |
|---|---|---|
constructor() | Elemento creado | Inicializar estado, adjuntar shadow root |
connectedCallback() | Insertado en el DOM | Renderizar, obtener datos, agregar event listeners |
disconnectedCallback() | Removido del DOM | Limpiar temporizadores, event listeners, suscripciones |
attributeChangedCallback() | Cambios en atributos observados | Re-renderizar, validar, actualizar estado interno |
adoptedCallback() | Movido a un nuevo documento | Raramente usado; limpiar y re-inicializar |
Dos trampas alrededor del lifecycle: connectedCallback se dispara cada vez que el elemento se (re)inserta — mover un nodo en el DOM lo activa — así que no asumas que corre una sola vez; y attributeChangedCallback puede dispararse antes de connectedCallback para atributos presentes en el marcado inicial, así que protege el código de render contra un elemento aún no conectado.
Integración con Frameworks
Web Components funcionan en cualquier framework.
// React
import 'my-ui-library';
function App() {
return (
<div>
<user-card name="Alice" role="Engineer" />
<alert-box type="success">
<h3 slot="title">Guardado</h3>
<p>Tus cambios han sido guardados.</p>
</alert-box>
</div>
);
}
<!-- Vue -->
<template>
<user-card :name="user.name" :role="user.role" />
</template>
<script>
import 'my-ui-library';
export default { props: ['user'] };
</script>
Dos detalles de integración muerden en la práctica. React < 19 pasa todo a los custom elements como atributos — objetos, arrays y funciones se serializan a string — y no enlaza los handlers onXyz a CustomEvents. React 19 corrigió ambos comportamientos; en versiones anteriores asignas properties vía ref o envuelves el elemento en un componente React fino. El segundo problema son los slots: los frameworks que renderizan hijos dentro de tu elemento (React, Vue) funcionan bien porque los nodos slotted son light DOM, pero el wrapper debe preservar el atributo slot="name" en ellos.
SSR e Hidratación
El renderizado en servidor de custom elements es el capítulo más flojo de la historia. El shadow DOM del elemento solo existe después de que customElements.define corre — no hay “hidratación” client-side de shadow roots como la que hace React con un árbol DOM. Los dos caminos viables:
- Shadow DOM declarativo: tu pipeline SSR serializa cada elemento con un hijo
<template shadowrootmode>(Lit SSR,@lit-labs/ssry@11ty/is-landde Eleventy lo hacen). El navegador convierte el marcado en shadow roots reales antes de que corran los scripts, así que nada parpadea y nada “hidrata” — el elemento simplemente despierta ya renderizado. - Islas solo-cliente: renderiza un placeholder en servidor y deja que el elemento se actualice en el cliente. Simple, pero produce un flash de marcado sin upgradear y pierde crawlability.
El SSR de frameworks (Next.js, Nuxt) no emite shadow DOM declarativo para custom elements hoy, así que los componentes dentro de páginas de framework siguen el camino 2 salvo que post-proceses el HTML. Si la corrección del SSR importa, renderiza el marcado de los componentes tú mismo o elige una librería (Lit) con soporte DSD de primera clase.
Consideraciones de Accesibilidad
El Shadow DOM cambia las reglas de accesibilidad en dos puntos fáciles de pasar por alto. Primero, las referencias ARIA no pueden cruzar la frontera shadow — un aria-labelledby dentro del shadow root no puede apuntar a un elemento del light DOM, ni al revés. Si una etiqueta debe vivir fuera, espejala con aria-label en el host o usa las propiedades ariaLabel/role de ElementInternals (el ejemplo asociado a formularios muestra cómo internals también es el puente para la semántica).
Segundo, el foco no atraviesa la frontera por defecto. attachShadow({ mode: 'open', delegatesFocus: true }) reenvía el foco al shadow tree para que los usuarios de teclado lleguen a las partes interactivas; sin ello, el tabulador se salta tu componente por completo. Prueba el orden de tabulación a mano — es la forma más rápida de detectar un componente invisible para el foco.
Todo lo demás es la disciplina de siempre: dale al host un role con sentido si se comporta como un widget, prefiere etiquetas visibles a tooltips title, y revisa el contraste del contenido slotted que tus estilos no controlan. La guía de WCAG cubre los criterios base que tus componentes siguen teniendo que cumplir — el shadow DOM no exime a nadie.
Testing de Web Components
Haz unit tests de custom elements como de cualquier otra clase — pero renderízalos en un navegador real, porque customElements.define y attachShadow no existen en la configuración por defecto de jsdom. Web Test Runner ejecuta los tests en un navegador de verdad:
import { fixture, expect } from '@open-wc/testing';
import '../user-card.js';
describe('user-card', () => {
it('renderiza el atributo name', async () => {
const el = await fixture('<user-card name="Ada"></user-card>');
const name = el.shadowRoot.querySelector('.name').textContent;
expect(name).to.equal('Ada');
});
it('re-renderiza cuando cambia un atributo observado', async () => {
const el = await fixture('<user-card name="Ada"></user-card>');
el.setAttribute('name', 'Grace');
expect(el.shadowRoot.querySelector('.name').textContent).to.equal('Grace');
});
});
Para E2E, los locators de Playwright atraviesan shadow roots abiertos automáticamente — page.getByRole('button') encuentra botones dentro de tu componente sin sintaxis especial, una de las sorpresas más agradables al migrar tests desde componentes de framework.
Todos los componentes de esta guía — user-card, alert-box y un rating-input asociado a formularios, además de una página demo y tests ejecutables con @open-wc — están en el repo complementario. Clónalo y sirve la carpeta estáticamente; no hay build.
Soporte de Navegadores y Polyfills
Todo lo de esta guía está disponible como baseline en todos los navegadores evergreen. Los bordes afilados son: los customized built-in elements (is=""), que Safari se niega a implementar — trátalos como mejora progresiva, nunca como tu API principal — y el Shadow DOM declarativo, que solo alcanzó soporte completo en 2024 (Firefox 123+).
Para soporte legacy real, el paquete @webcomponents/polyfills cubre Custom Elements, Shadow DOM y templates hasta navegadores de la era IE11, pero cargar el polyfill cuesta ~60KB y rara vez merece la pena hoy — revisa primero tu matriz de navegadores real.
Errores Comunes
- Olvidar manejar cambios de atributos — los atributos observados deben disparar re-renderizado
- Usar
innerHTMLen el elemento host — siempre usa Shadow DOM para el marcado del componente - No limpiar en
disconnectedCallback()— fugas de memoria de event listeners huérfanos - Asumir que
constructorcorre después de inserción en DOM — corre en creación; el DOM puede no existir aún - Faltar polyfills para navegadores antiguos — Edge 18 e IE11 necesitan el polyfill de webcomponentsjs
Solución de Problemas
attributeChangedCallbacknunca se dispara: el atributo no está enobservedAttributes, o estás asignando una property en lugar de un atributo. Solo los atributos listados disparan el callback, yel.prop = xnunca lo hace — usael.setAttribute('prop', x)o implementa un setter que refleje al atributo.- Los eventos del componente nunca llegan al padre: el listener está fuera de la frontera shadow y el evento no se despachó con
composed: true.bubblespor sí solo no basta — revisa los flags del evento en la llamada a dispatch. - Los estilos de
::slotted()no se aplican:::slotted()solo empareja el elemento asignado directamente al slot, no sus descendientes —::slotted(h3)funciona,::slotted(h3 span)nunca. Estila nodos más profundos con CSS custom properties. - El componente renderiza dos veces o pierde estado al re-renderizar: el método render reconstruye
innerHTML, lo que destruye listeners hijos y el foco. Haz diff y actualiza solo lo que cambió, o mantén un patrón render-once + actualizar-nodos como en los ejemplos de arriba. - Los estilos de la página se cuelan dentro del componente (o no): la herencia cruza la frontera shadow para propiedades CSS heredables (font, color) pero no para selectores de elemento. Si un
button { ... }global estila tu botón shadow, no adjuntaste shadow root; si una variable CSS no se aplica, la estás definiendo en el ancestro equivocado. - El
<form>ignora el custom element: te faltastatic formAssociated = true— sin ello,attachInternals()lanza una excepción y el elemento no envía nada.
Puntos Clave
- Atributos hacia dentro, eventos
composedhacia fuera — esa es toda la superficie pública de API de un custom element bien construido. - El Shadow DOM es lo que hace portable al componente: la encapsulación de estilos es la feature, no una molestia que esquivar.
- Usa CSS custom properties como contrato de theming entre el componente y la página; documéntalas como si fueran props.
- Evita los customized built-in elements para cualquier cosa de cara al usuario — Safari no implementará
is="", así que los elementos autónomos más Shadow DOM declarativo cubren más terreno. - Testea en un navegador real (Web Test Runner o Playwright); el soporte de shadow DOM en jsdom tiene demasiados huecos para ser fiable.
Lecturas Adicionales
- MDN — Web Components — la referencia de Custom Elements, Shadow DOM y templates.
- web.dev — Custom Elements v1 y Shadow DOM v1 — recorridos prácticos de ambas APIs.
- HTML Standard — Custom Elements — la spec WHATWG, para detalles de lifecycle y edge cases.
- Open Web Components — guías y recomendaciones de tooling para construir librerías WC.
Preguntas frecuentes
¿Los Web Components reemplazan React/Vue/Angular?
No — los complementan. Usa Web Components para elementos de design system compartidos entre frameworks o para widgets embebibles; quédate con componentes de framework para UI específica de la app con mucho estado.
¿Pueden los Web Components usar frameworks CSS externos?
Los estilos globales no entran al shadow root. Carga el CSS del framework dentro de cada shadow root (costoso), o expón una API de estilos con CSS custom properties y ::part() — la segunda opción es la que usan los design systems.
¿Cómo testeo Web Components?
Haz unit tests en un navegador real con Web Test Runner y @open-wc/testing. Para E2E, los locators de Playwright atraviesan shadow roots abiertos, así que getByRole y getByText funcionan directamente.
¿Los buscadores indexan contenido dentro del shadow DOM?
Google renderiza JavaScript y aplana shadow roots abiertos, pero depender de eso para contenido indexable es arriesgado — mantén el marcado crítico para SEO en el light DOM o envía Shadow DOM declarativo desde el servidor.
¿Qué diferencia hay entre un atributo y una property en un custom element?
Los atributos son strings en el HTML (name="Ada"); las properties son valores JS en la instancia (el.user = {...}). Los datos ricos deben llegar por properties; los atributos son para configuración que pertenece al marcado.
¿Debería usar shadow roots cerrados para proteger los internals?
No — mode: 'closed' oculta element.shadowRoot, lo que rompe herramientas de testing y workarounds de accesibilidad sin añadir seguridad real. Los roots abiertos son el default del ecosistema.
Recursos Relacionados
WCAG 2.2 Accesibilidad: Guía para Desarrolladores
Una guía práctica de cumplimiento WCAG 2.2: los cuatro principios POUR, ARIA, navegación por teclado, formularios y modales accesibles, con ejemplos de código reales.
GuideProgressive Web Apps (PWA) — Referencia Detallada
Referencia Detallada para construir Progressive Web Apps: service workers, soporte offline, Web App Manifest, notificaciones push e instalabilidad.
PatternPatrón Islands Architecture: Hidratación Parcial
Renderiza la página como HTML estático en el servidor e hidrata solo las islas interactivas. Cubre las directivas client: de Astro, hidratación parcial, estado compartido y cuándo las islas superan a una SPA.
GuideGuía Completa de Diseño Responsive para Móviles
Construye layouts que funcionan en cualquier pantalla: breakpoints mobile-first, Grid, Flexbox, container queries, tipografía fluida e imágenes responsive.
PatternPatrón Progressive Enhancement
Construye una base HTML funcional y añade capas de CSS y JavaScript encima. Cubre detección de características, degradación elegante y fallbacks sin JS.
GuideGuía completa de optimización de rendimiento en React
Optimiza aplicaciones React: memoización, virtualización, división de código, análisis del bundle, React Profiler, características concurrentes y Core Web Vitals.