Todo el contenido que <task-card> ha mostrado hasta ahora —el título, la insignia de estado, el aviso de urgencia— lo genera el propio componente, dentro de su render(), a partir de sus propiedades reactivas. Pero hay un caso distinto, muy habitual en interfaces reales, que todavía no se ha cubierto: un componente que recibe contenido ya construido desde fuera y necesita colocarlo en un punto concreto de su propia plantilla interna. Esta lección presenta el elemento <slot>, el mecanismo estándar de los Web Components para este escenario, explica cómo estilizar ese contenido distribuido desde dentro con ::slotted(), y lo aplica creando <user-avatar>, un nuevo componente de TaskFlow que <task-card> usará para mostrar la persona asignada a cada tarea.

Contenido

  1. El problema: contenido que viene de fuera, no de las propiedades
  2. Qué es un <slot> y cómo distribuye contenido
  3. Slot por defecto frente a slots con nombre
  4. Estilizando contenido distribuido con ::slotted()
  5. Las limitaciones de ::slotted()
  6. Creando <user-avatar>
  7. Usando <user-avatar> dentro de <task-card>
  8. Cierre del módulo: hacia la comunicación entre componentes

  1. El problema: contenido que viene de fuera, no de las propiedades

Hasta ahora, cada vez que <task-card> ha necesitado mostrar información variable, esa información ha llegado como una propiedad reactiva de tipo simple: una cadena de texto (titulo), un número (prioridad), un booleano (urgente). El patrón siempre ha sido el mismo: el dato entra como propiedad, render() lo interpola dentro de una plantilla que el propio componente controla por completo.

Pero imagina ahora que se quiere mostrar, dentro de cada tarjeta, un pequeño indicador visual de la persona asignada a la tarea: una imagen, o quizá solo sus iniciales sobre un fondo de color. Se podría resolver con más propiedades (nombreAsignado, imagenAsignado...), pero hay una alternativa distinta, más flexible, cuando lo que se quiere pasar no es un dato simple sino contenido HTML ya construido, potencialmente distinto según el caso: a veces una imagen, a veces solo texto, a veces un icono. Ese es exactamente el escenario para el que existen los <slot>.

  1. Qué es un <slot> y cómo distribuye contenido

Un <slot> es un elemento especial, reconocido de forma nativa por el navegador (no es una invención de Lit; forma parte del estándar de Web Components desde su origen), que se coloca dentro del Shadow DOM de un componente y actúa como un "hueco": cualquier contenido que se escriba entre las etiquetas de apertura y cierre del elemento personalizado, en el DOM ligero (fuera del shadow root), se distribuye automáticamente dentro de ese hueco cuando el navegador compone el árbol final que se muestra en pantalla.

import { LitElement, html, css } from 'lit';

class UserAvatar extends LitElement {
  static styles = css`
    :host {
      display: inline-block;
    }
  `;

  render() {
    return html`<div class="avatar"><slot></slot></div>`;
  }
}

customElements.define('user-avatar', UserAvatar);
<user-avatar>AC</user-avatar>

En este ejemplo, el texto AC, escrito entre las etiquetas <user-avatar> y </user-avatar> en el HTML normal (fuera de cualquier shadow root), no forma parte de la plantilla de render() en ningún sentido literal: render() no sabe, ni necesita saber, qué contenido concreto va a distribuirse. Lo que ocurre es que el navegador, al renderizar visualmente el árbol final, "proyecta" ese texto AC dentro del <slot> que aparece en el shadow root, de forma que en pantalla aparece exactamente como si AC estuviera escrito directamente dentro del <div class="avatar">, aunque en el árbol lógico del DOM el texto siga perteneciendo, en realidad, al elemento ligero <user-avatar>, no al shadow root.

Este mecanismo es completamente distinto de pasar una propiedad: no hay ningún static properties implicado, ni ninguna conversión de tipos, ni ninguna reactividad de Lit en juego. El slot es, literalmente, una técnica de composición visual del propio navegador, que Lit no reinventa sino que expone con total naturalidad porque su render() genera HTML normal, capaz de incluir cualquier elemento estándar, <slot> incluido.

  1. Slot por defecto frente a slots con nombre

El ejemplo anterior usa un slot por defecto (<slot></slot>, sin ningún atributo name): recoge todo el contenido distribuible que no esté marcado explícitamente para otro slot. Un componente puede tener, además, varios slots con nombre, cada uno de los cuales recoge solo el contenido que, desde fuera, se marque con el atributo slot correspondiente:

render() {
  return html`
    <div class="avatar">
      <slot name="imagen"></slot>
      <slot></slot>
    </div>
  `;
}
<user-avatar>
  <img slot="imagen" src="ana.jpg" alt="Ana" />
  AC
</user-avatar>

Aquí, el <img> con slot="imagen" se distribuye dentro del <slot name="imagen">, mientras que el texto AC (que no lleva ningún atributo slot, y por tanto no está destinado a ningún slot con nombre concreto) se distribuye dentro del slot por defecto. Un componente puede tener tantos slots con nombre como necesite, permitiendo repartir distintas piezas de contenido distribuido en distintos puntos de la plantilla interna, mientras que el slot por defecto (como mucho uno por componente, sin name) recoge todo lo demás. Para <user-avatar>, como se verá en el apartado 6, basta con un único slot por defecto, ya que solo hay un tipo de contenido que distribuir a la vez: o bien las iniciales, o bien una imagen, nunca ambas.

  1. Estilizando contenido distribuido con ::slotted()

El contenido que llega a través de un <slot> plantea una pregunta natural: ¿desde dónde se le puede dar estilo? Como ese contenido, en el árbol lógico del DOM, sigue perteneciendo al documento exterior (al DOM ligero de <user-avatar>, no a su shadow root), el CSS declarado dentro de static styles no lo alcanza con un selector normal como img { ... }: esa regla solo afecta a elementos <img> que estén realmente dentro del shadow root, y el <img> distribuido, aunque se vea visualmente en ese lugar, no lo está en términos del árbol lógico del DOM.

Para este caso concreto, CSS ofrece un pseudo-elemento dedicado: ::slotted(), que permite seleccionar, desde dentro del shadow root, el contenido que un <slot> está distribuyendo en ese momento.

static styles = css`
  ::slotted(img) {
    border-radius: 50%;
    width: 2rem;
    height: 2rem;
    object-fit: cover;
  }
`;

Esta regla selecciona cualquier elemento <img> que esté siendo distribuido por algún <slot> del componente, y le aplica un border-radius: 50% (para darle forma circular, un tratamiento típico de un avatar de usuario) junto con un tamaño fijo. Nótese la sintaxis: ::slotted() es un pseudo-elemento (con dos dos-puntos, igual que ::before o ::after del CSS estándar), no una pseudo-clase como :host(), y recibe entre paréntesis un selector que describe qué contenido distribuido seleccionar.

  1. Las limitaciones de ::slotted()

::slotted() es deliberadamente limitado en lo que puede seleccionar, y conviene conocer esa limitación de antemano para no perder tiempo depurando un selector que, sencillamente, no está soportado por el estándar:

  • Solo admite selectores simples, aplicados directamente sobre el elemento distribuido de nivel superior: una etiqueta (::slotted(img)), una clase (::slotted(.avatar-imagen)), un atributo (::slotted([data-tipo="imagen"])). No admite combinadores de descendencia: ::slotted(div span) no es válido y el navegador lo ignora directamente.
  • No permite "entrar" dentro del contenido distribuido: si el contenido distribuido es un elemento con hijos propios (por ejemplo, un <div> que a su vez contiene un <span>), ::slotted() solo puede seleccionar ese <div> de nivel superior, nunca el <span> que hay dentro de él. La regla ::slotted(div) span { ... }, con un combinador después del pseudo-elemento, tampoco es válida.
  • Solo selecciona los nodos distribuidos directamente por el slot, no cualquier elemento anidado más profundamente en la jerarquía del contenido distribuido.

Esta limitación no es un descuido: es una decisión deliberada del estándar para mantener la misma filosofía de encapsulación que se ha defendido en todo este módulo. Si ::slotted() permitiera selectores de descendencia arbitrarios, un componente podría acabar dando estilo a la estructura interna de un contenido que, en última instancia, no controla ni conoce de antemano (el contenido distribuido lo decide quien usa el componente, no el componente mismo), lo que rompería la misma separación de responsabilidades que el Shadow DOM persigue en el resto de este módulo.

En la práctica, esta limitación empuja hacia una convención sensata: mantener el contenido que se distribuye a través de un slot razonablemente simple (una imagen suelta, un fragmento de texto, un icono), dejando cualquier estructura interna más compleja para plantillas generadas por el propio componente en lugar de contenido distribuido.

  1. Creando <user-avatar>

Con la teoría cubierta, es el momento de construir <user-avatar> completo, como nuevo componente de TaskFlow: un componente pequeño y reutilizable, pensado para mostrar a la persona asignada a una tarea, ya sea con una imagen o con sus iniciales como alternativa cuando no hay imagen disponible.

// src/components/user-avatar.js
import { LitElement, html, css } from 'lit';

class UserAvatar extends LitElement {
  static properties = {
    nombre: { type: String },
  };

  static styles = css`
    :host {
      display: inline-block;
      --tamano-avatar: 2rem;
    }

    .avatar {
      width: var(--tamano-avatar);
      height: var(--tamano-avatar);
      border-radius: 50%;
      background-color: #cbd5e1;
      color: #1f2933;
      display: flex;
      align-items: center;
      justify-content: center;
      font-size: 0.8rem;
      font-weight: bold;
      overflow: hidden;
    }

    ::slotted(img) {
      width: 100%;
      height: 100%;
      object-fit: cover;
    }
  `;

  constructor() {
    super();
    this.nombre = '';
  }

  render() {
    return html`
      <div class="avatar" title="${this.nombre}">
        <slot>${this.iniciales()}</slot>
      </div>
    `;
  }

  iniciales() {
    if (!this.nombre) {
      return '?';
    }
    return this.nombre
      .split(' ')
      .map((palabra) => palabra.charAt(0).toUpperCase())
      .slice(0, 2)
      .join('');
  }
}

customElements.define('user-avatar', UserAvatar);

Varios detalles merecen explicación. Primero, <user-avatar> combina, dentro de una misma plantilla, lo ya visto en este módulo y en el anterior: una propiedad reactiva nombre (de tipo String, siguiendo el patrón del módulo 3), una variable CSS --tamano-avatar declarada en :host con su valor por defecto (siguiendo el patrón de theming de la lección anterior), y ahora un <slot> con contenido de repuesto entre sus etiquetas de apertura y cierre.

Ese contenido de repuesto —<slot>${this.iniciales()}</slot>— es un detalle importante del estándar de <slot>: cualquier contenido escrito directamente dentro de las etiquetas <slot>...</slot> en la plantilla del componente se muestra solo cuando no hay ningún contenido distribuido desde fuera que ocupe ese slot. Si quien usa <user-avatar> no escribe nada entre sus etiquetas (<user-avatar nombre="Ana Costa"></user-avatar>), el slot está vacío y el navegador muestra automáticamente el contenido de repuesto: en este caso, el resultado de this.iniciales(), que calcula las iniciales del nombre a partir de la propiedad reactiva nombre (aprovechando aquí sí una propiedad normal, porque las iniciales son un dato derivado de texto simple, no contenido HTML complejo). Si, en cambio, se distribuye una imagen desde fuera, esa imagen sustituye por completo al contenido de repuesto.

La regla ::slotted(img) da a cualquier imagen distribuida un tamaño que llena completamente el círculo del avatar, recortándola de forma proporcionada con object-fit: cover, exactamente el uso de ::slotted() presentado en el apartado 4.

  1. Usando <user-avatar> dentro de <task-card>

Con <user-avatar> ya construido, se incorpora dentro de <task-card>, tanto en forma de iniciales (sin contenido distribuido, usando el nombre como propiedad) como, opcionalmente, con una imagen real cuando la tarea la tenga disponible:

import { LitElement, html, css } from 'lit';
import { estilosCompartidos } from '../styles/shared-styles.js';
import './user-avatar.js';

class TaskCard extends LitElement {
  static properties = {
    titulo: { type: String },
    estado: { type: String },
    prioridad: { type: Number },
    urgente: { type: Boolean },
    asignadoA: { type: String },
    asignadoImagen: { type: String },
    expandida: { state: true },
    fechaLimite: { converter: conversorDeFecha, attribute: 'fecha-limite' },
  };

  static styles = [
    estilosCompartidos,
    css`
      /* ...reglas ya vistas en las lecciones anteriores... */

      .cabecera {
        display: flex;
        align-items: center;
        gap: 0.5rem;
      }
    `,
  ];

  constructor() {
    super();
    this.titulo = 'Tarea sin título';
    this.estado = 'pendiente';
    this.prioridad = 3;
    this.urgente = false;
    this.asignadoA = '';
    this.asignadoImagen = '';
    this.expandida = false;
    this.fechaLimite = null;
  }

  renderAvatar() {
    if (this.asignadoImagen) {
      return html`
        <user-avatar nombre="${this.asignadoA}">
          <img src="${this.asignadoImagen}" alt="${this.asignadoA}" />
        </user-avatar>
      `;
    }
    return html`<user-avatar nombre="${this.asignadoA}"></user-avatar>`;
  }

  render() {
    return html`
      <article @click="${this.alternarExpandida}">
        <div class="cabecera">
          ${this.renderAvatar()}
          <h3>${this.titulo}</h3>
        </div>
        ${this.renderInsigniaEstado()}
        <p>Prioridad: ${this.prioridad}</p>
        ${this.renderFechaLimite()}
        ${this.urgente && html`<p class="aviso">⚠ Urgente</p>`}
        ${this.expandida
          ? html`<div class="detalle"><p>Estado interno: la tarjeta está expandida.</p></div>`
          : ''}
      </article>
    `;
  }
}

customElements.define('task-card', TaskCard);

renderAvatar() decide, según si la tarea tiene o no una imagen asociada (una nueva propiedad asignadoImagen, de tipo String), si se distribuye un <img> dentro de <user-avatar> o si se deja el slot vacío para que aparezcan las iniciales calculadas automáticamente. En ambos casos se pasa nombre como atributo normal, ya que <user-avatar> lo necesita tanto para calcular las iniciales de repuesto como para el atributo title de accesibilidad visto en su plantilla. Este patrón —un componente que decide, según sus propios datos, qué contenido distribuir dentro de otro componente hijo— combina, en una sola pieza de TaskFlow, todo lo visto hasta ahora: propiedades reactivas y datos (módulo 3), estilos encapsulados y compartidos, variables CSS de theming, y ahora slots con contenido distribuido y estilizado mediante ::slotted().

  1. Cierre del módulo: hacia la comunicación entre componentes

Con esta lección se completa el módulo 4, "Estilos en Componentes Lit". El recorrido ha ido de la base a lo más avanzado: primero, la doble frontera de encapsulación que levanta el Shadow DOM y la forma correcta de declarar CSS con static styles y css; después, cómo evitar la duplicación extrayendo estilos compartidos entre varios componentes; a continuación, las variables CSS como la única grieta deliberada de esa frontera, y su uso como mecanismo de theming; y, en esta última lección, los slots como forma de recibir e integrar contenido distribuido desde fuera, con ::slotted() como herramienta, limitada pero suficiente, para darle estilo desde dentro.

TaskFlow, como resultado de todo el módulo, ha dado un salto visual completo: <task-card> tiene ya una apariencia cuidada, con bordes, tipografía y colores de estado configurables mediante variables CSS; <task-list> organiza las tarjetas en una columna ordenada compartiendo la misma base tipográfica; y un nuevo componente, <user-avatar>, se integra dentro de cada tarjeta para mostrar, mediante contenido distribuido con slots, a la persona asignada a cada tarea, con imagen real o con iniciales de repuesto. Los datos ya eran reactivos desde el módulo 3; ahora, además, se ven bien.

Sin embargo, si has estado siguiendo el curso probando estos ejemplos en el navegador, es fácil notar que TaskFlow sigue siendo, en el fondo, una interfaz pasiva: el clic sobre una tarjeta solo alterna un estado interno visual (expandida, visto en el módulo 3), y no existe ninguna forma real de que el usuario marque una tarea como completada, cambie su prioridad, o de que <task-card> avise a <task-list> de que algo ha cambiado. Ahora que TaskFlow se ve bien, falta que sus componentes se comuniquen entre sí de verdad: que un clic sobre una tarjeta pueda, por ejemplo, notificar a su componente padre que el usuario quiere cambiar el estado de una tarea. Esa es exactamente la tarea del módulo 5, "Eventos y Comunicación entre Componentes".

Errores Comunes y Consejos

  • Esperar que un selector normal, como img { ... }, alcance el contenido distribuido: como se explicó en el apartado 4, un selector normal dentro de static styles solo afecta a elementos que estén realmente dentro del shadow root; el contenido distribuido por un <slot> necesita ::slotted() para poder recibir estilo desde dentro del componente.
  • Intentar usar un selector de descendencia dentro de ::slotted(): como se detalló en el apartado 5, ::slotted(div span) o ::slotted(div) span no son selectores válidos; ::slotted() solo admite un selector simple aplicado al nodo distribuido de nivel superior, nunca a sus descendientes.
  • Olvidar que el contenido de repuesto de un <slot> solo aparece cuando el slot está realmente vacío: si se distribuye cualquier contenido, aunque sea un espacio en blanco o un comentario HTML no vacío, el contenido de repuesto escrito dentro de <slot>...</slot> deja de mostrarse; conviene comprobar, si el contenido de repuesto no aparece cuando se esperaba, que realmente no se está distribuyendo nada desde fuera.
  • Confundir el atributo slot="nombre" de un elemento distribuido con el atributo name del propio <slot>: el <slot name="imagen"> de la plantilla interna define el nombre del hueco; el elemento que se quiere distribuir en ese hueco concreto necesita el atributo slot="imagen" (mismo valor, atributo distinto) en el HTML exterior, como se mostró en el apartado 3. Confundir ambos atributos es una fuente frecuente de "el slot con nombre no recibe nada".

Ejercicios

  1. Añade a <user-avatar> un segundo slot con nombre, estado-conexion, pensado para distribuir un pequeño indicador (por ejemplo, un <span> con un punto de color) que muestre si la persona está conectada, y colócalo en la plantilla junto al slot por defecto ya existente.
  2. Escribe una regla ::slotted(span) dentro de static styles de <user-avatar> que dé al indicador de conexión del ejercicio anterior un tamaño pequeño y forma circular, y explica por qué esta regla no afectaría, por ejemplo, a un <span> anidado dentro de un <div> que también se distribuyera al mismo slot.
  3. Explica con tus propias palabras, basándote en los apartados 1 y 6, por qué tiene más sentido usar un <slot> para el avatar de la persona asignada (que puede ser una imagen o un texto de iniciales) que declarar dos propiedades reactivas separadas, mostrarImagen y urlImagen, y decidir en render() cuál de las dos usar.

Soluciones

render() {
  return html`
    <div class="avatar" title="${this.nombre}">
      <slot>${this.iniciales()}</slot>
    </div>
    <slot name="estado-conexion"></slot>
  `;
}
<user-avatar nombre="Ana Costa">
  <span slot="estado-conexion" class="punto-conectado"></span>
</user-avatar>
::slotted(span) {
  width: 0.5rem;
  height: 0.5rem;
  border-radius: 50%;
  display: inline-block;
}

Esta regla afecta únicamente al <span> que se distribuye directamente a un slot de <user-avatar> (el nodo de nivel superior del contenido distribuido); si ese <span> estuviera anidado dentro de un <div> que fuera, a su vez, el elemento realmente distribuido al slot, ::slotted(span) no lo alcanzaría, porque, como se explicó en el apartado 5, ::slotted() no puede "entrar" en la estructura interna del contenido distribuido, solo seleccionar el propio nodo de nivel superior.

  1. Como se explica en el apartado 1, lo que varía aquí no es un dato simple (un texto, un número, un booleano) sino la propia naturaleza del contenido a mostrar: a veces una imagen completa con su propio src y alt, a veces un simple texto de iniciales. Modelarlo con dos propiedades (mostrarImagen, urlImagen) obligaría a <user-avatar> a conocer de antemano todos los tipos de contenido posibles y a construir él mismo la etiqueta <img> o el texto según un booleano, mientras que un <slot> traslada esa decisión a quien usa el componente, que puede distribuir literalmente cualquier HTML válido (una imagen, un icono SVG, o nada en absoluto, dejando las iniciales de repuesto) sin que <user-avatar> necesite ninguna propiedad adicional ni ninguna lógica nueva para contemplar ese caso.

Conclusión

En esta última lección del módulo 4 has aprendido qué es un <slot> y cómo distribuye contenido HTML desde fuera del shadow root de un componente, la diferencia entre el slot por defecto y los slots con nombre, y cómo dar estilo a ese contenido distribuido desde dentro con ::slotted(), con sus limitaciones deliberadas de selectores simples sin descendencia. Has aplicado todo ello creando <user-avatar>, un nuevo componente de TaskFlow con contenido de repuesto calculado a partir de una propiedad reactiva y un slot capaz de recibir tanto iniciales como una imagen real, y lo has integrado dentro de <task-card> para mostrar la persona asignada a cada tarea.

Con esto se cierra el módulo "Estilos en Componentes Lit": TaskFlow tiene ya un aspecto visual completo, coherente y personalizable mediante variables CSS, con un componente adicional, <user-avatar>, que amplía el sistema mediante contenido distribuido. Ahora que se ve bien, falta que los componentes se comuniquen entre sí: en el módulo 5, "Eventos y Comunicación entre Componentes", aprenderás a hacer que, por ejemplo, un clic sobre una tarjeta o un cambio en su estado se propaguen realmente entre <task-card>, <task-list> y el resto de componentes de TaskFlow.

Curso de Lit

Módulo 1: Introducción a Lit y Web Components

Módulo 2: Plantillas Reactivas y Renderizado

Módulo 3: Propiedades y Estado Reactivo

Módulo 4: Estilos en Componentes Lit

Módulo 5: Eventos y Comunicación entre Componentes

Módulo 6: Ciclo de Vida y Comportamiento Avanzado

Módulo 7: Directivas y Funcionalidades Avanzadas de Plantillas

Módulo 8: Integración, Interoperabilidad y Despliegue

Módulo 9: Pruebas y Buenas Prácticas

Módulo 10: Proyecto: Construyendo TaskFlow

© Copyright 2026. Todos los derechos reservados