Web Components: una introducción práctica
Cómo crear componentes web independientes del framework con Custom Elements, Shadow DOM y HTML Templates: piezas de UI reutilizables en cualquier sitio.

Los web components te permiten crear elementos de interfaz reutilizables que funcionan en cualquier framework, o sin ningún framework. Se basan en tres APIs del navegador: Custom Elements (para definir nuevas etiquetas HTML), Shadow DOM (estilos y marcado encapsulados) y HTML Templates (fragmentos de marcado reutilizables). A diferencia de los componentes de React o Vue, los web components son un estándar del navegador. Un componente creado hoy seguirá funcionando dentro de diez años sin necesidad de un paso de compilación.
El caso de uso más práctico son los sistemas de diseño. Si tu organización tiene equipos que usan React, Angular y JavaScript puro, los web components te permiten construir elementos de interfaz compartidos una sola vez y usarlos en todas partes.
Custom Elements
Custom Elements te permite definir nuevas etiquetas HTML con comportamiento personalizado. El navegador las trata como elementos nativos — funcionan en HTML, se pueden consultar con document.querySelector y disparan eventos.
// Define a custom element
class UserCard extends HTMLElement {
// Observed attributes trigger attributeChangedCallback
static observedAttributes = ['name', 'email', 'avatar'];
constructor() {
super();
// Attach shadow DOM for style encapsulation
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
// Called when element is added to the DOM
this.render();
}
attributeChangedCallback(
name: string,
oldValue: string | null,
newValue: string | null
) {
// Called when an observed attribute changes
if (oldValue !== newValue) {
this.render();
}
}
private render() {
const name = this.getAttribute('name') ?? 'Unknown';
const email = this.getAttribute('email') ?? '';
const avatar = this.getAttribute('avatar') ?? '';
this.shadowRoot!.innerHTML = `
<style>
:host {
display: block;
font-family: system-ui, sans-serif;
}
.card {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
border: 1px solid #e2e8f0;
border-radius: 8px;
}
.avatar {
width: 48px;
height: 48px;
border-radius: 50%;
object-fit: cover;
}
.name {
font-weight: 600;
font-size: 1rem;
}
.email {
color: #64748b;
font-size: 0.875rem;
}
</style>
<div class="card">
${avatar ? `<img class="avatar" src="${this.escapeHtml(avatar)}" alt="${this.escapeHtml(name)}" />` : ''}
<div>
<div class="name">${this.escapeHtml(name)}</div>
<div class="email">${this.escapeHtml(email)}</div>
</div>
</div>
`;
}
private escapeHtml(text: string): string {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
}
// Register the custom element
customElements.define('user-card', UserCard);<!-- Usage — works in any HTML page, any framework -->
<user-card
name="Sarah Chen"
email="sarah@example.com"
avatar="/avatars/sarah.jpg"
></user-card>
<!-- Dynamically create in JavaScript -->
<script>
const card = document.createElement('user-card');
card.setAttribute('name', 'Alex Rivera');
card.setAttribute('email', 'alex@example.com');
document.body.appendChild(card);
</script>Encapsulación con Shadow DOM
Shadow DOM crea un árbol DOM aislado dentro de tu componente. Los estilos definidos dentro del shadow root no se filtran hacia afuera, y los estilos externos no se filtran hacia adentro. Esta es la gran ventaja frente a los componentes de framework — una verdadera encapsulación de estilos a nivel del navegador.
// ❌ Without Shadow DOM — styles leak
class BadButton extends HTMLElement {
connectedCallback() {
this.innerHTML = `
<style>
button { background: red; color: white; }
/* This style affects ALL buttons on the page! */
</style>
<button>Click me</button>
`;
}
}
// ✅ With Shadow DOM — styles are encapsulated
class GoodButton extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.shadowRoot!.innerHTML = `
<style>
button {
background: #3b82f6;
color: white;
border: none;
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
font-size: 0.875rem;
}
button:hover {
background: #2563eb;
}
/* Only affects the button inside this shadow root */
</style>
<button><slot></slot></button>
`;
}
}
customElements.define('ui-button', GoodButton);<!-- Slot allows passing content from light DOM -->
<ui-button>Save Changes</ui-button>
<ui-button>Cancel</ui-button>Slots y composición
Los slots permiten que quien consume tu componente le pase contenido, de forma similar a la prop children de React o a los slots de Vue.
class AlertBox extends HTMLElement {
static observedAttributes = ['type'];
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.render();
}
attributeChangedCallback() {
this.render();
}
private render() {
const type = this.getAttribute('type') ?? 'info';
const colors: Record<string, { bg: string; border: string }> = {
info: { bg: '#eff6ff', border: '#3b82f6' },
success: { bg: '#f0fdf4', border: '#22c55e' },
warning: { bg: '#fffbeb', border: '#f59e0b' },
error: { bg: '#fef2f2', border: '#ef4444' },
};
const color = colors[type] ?? colors.info;
this.shadowRoot!.innerHTML = `
<style>
:host {
display: block;
}
.alert {
padding: 12px 16px;
border-left: 4px solid ${color.border};
background: ${color.bg};
border-radius: 4px;
}
.title {
font-weight: 600;
margin-bottom: 4px;
}
::slotted(*) {
margin: 0;
}
</style>
<div class="alert">
<div class="title"><slot name="title"></slot></div>
<div class="body"><slot></slot></div>
</div>
`;
}
}
customElements.define('alert-box', AlertBox);<!-- Named and default slots -->
<alert-box type="warning">
<span slot="title">Rate Limit Warning</span>
<p>You have 5 API calls remaining in the current window.</p>
</alert-box>
<alert-box type="success">
<span slot="title">Deployment Complete</span>
<p>Version 2.3.1 is now live in production.</p>
</alert-box>Eventos personalizados
Los web components se comunican con el exterior mediante eventos personalizados, siguiendo el mismo patrón que los eventos nativos del DOM.
class SearchInput extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.shadowRoot!.innerHTML = `
<style>
input {
width: 100%;
padding: 8px 12px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 1rem;
box-sizing: border-box;
}
input:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
</style>
<input type="search" placeholder="${this.getAttribute('placeholder') ?? 'Search...'}" />
`;
const input = this.shadowRoot!.querySelector('input')!;
let debounceTimer: ReturnType<typeof setTimeout>;
input.addEventListener('input', () => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
// Dispatch custom event with search value
this.dispatchEvent(
new CustomEvent('search', {
detail: { query: input.value },
bubbles: true, // Propagates up the DOM
composed: true, // Crosses shadow DOM boundary
})
);
}, 300);
});
}
}
customElements.define('search-input', SearchInput);<search-input placeholder="Search users..."></search-input>
<script>
document
.querySelector('search-input')
.addEventListener('search', (event) => {
console.log('Search query:', event.detail.query);
// Fetch results, filter list, etc.
});
</script>Uso de Web Components en frameworks
Los web components funcionan en React, Vue, Angular y Svelte. La integración con frameworks es, en general, fluida, con algunas particularidades a la hora de pasar propiedades frente a establecer atributos.
// React — use web components directly in JSX
function UserList({ users }: { users: User[] }) {
return (
<div>
{users.map((user) => (
<user-card
key={user.id}
name={user.name}
email={user.email}
avatar={user.avatar}
/>
))}
</div>
);
}
// React — listening to custom events requires a ref
function SearchPage() {
const searchRef = useRef<HTMLElement>(null);
useEffect(() => {
const el = searchRef.current;
if (!el) return;
const handler = (e: Event) => {
const query = (e as CustomEvent).detail.query;
console.log('Search:', query);
};
el.addEventListener('search', handler);
return () => el.removeEventListener('search', handler);
}, []);
return <search-input ref={searchRef} placeholder="Search..." />;
}// ❌ Passing complex data as attributes (serialized to strings)
<user-card settings='{"theme":"dark","lang":"en"}'></user-card>
// Attributes are always strings — parsing JSON on every render is wasteful
// ✅ Passing complex data as properties
const card = document.querySelector('user-card') as UserCard;
card.settings = { theme: 'dark', lang: 'en' };
// Properties can be any JavaScript type — no serialization neededConclusiones clave
- Los web components son un estándar del navegador — funcionan en todas partes sin necesidad de un paso de compilación y no se rompen cuando cambian los frameworks
- Shadow DOM ofrece una encapsulación de estilos real — los estilos dentro de un componente no pueden filtrarse hacia afuera, ni los estilos externos filtrarse hacia adentro
- Usa los slots para la composición — los slots con nombre y los slots por defecto permiten que quien consume tu componente le pase contenido, como props.children en React
- Dispara eventos personalizados para comunicarte — define
bubbles: trueycomposed: truepara que los eventos atraviesen los límites del shadow DOM - Prefiere las propiedades a los atributos para datos complejos — los atributos siempre son strings; las propiedades aceptan cualquier tipo de JavaScript
- Ideal para sistemas de diseño — cuando varios frameworks necesitan los mismos elementos de interfaz, los web components son la base compartida


