Saltar al contenido

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.

5 min de lectura
Diagrama de arquitectura de un web component que muestra el custom element, el shadow DOM y la composición de templates

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.

tstypescript
// 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);
htmlhtml
<!-- 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.

tstypescript
// ❌ 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);
htmlhtml
<!-- 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.

tstypescript
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);
htmlhtml
<!-- 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.

tstypescript
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);
htmlhtml
<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.

tstypescript
// 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..." />;
}
tstypescript
// ❌ 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 needed

Conclusiones clave

  1. 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
  2. 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
  3. 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
  4. Dispara eventos personalizados para comunicarte — define bubbles: true y composed: true para que los eventos atraviesen los límites del shadow DOM
  5. Prefiere las propiedades a los atributos para datos complejos — los atributos siempre son strings; las propiedades aceptan cualquier tipo de JavaScript
  6. Ideal para sistemas de diseño — cuando varios frameworks necesitan los mismos elementos de interfaz, los web components son la base compartida
Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX