El módulo 3 ha dejado a <task-card> y <task-list> completamente resueltas en cuanto a datos: propiedades reactivas, estado interno, conversores personalizados y una lista que pasa a cada tarjeta hija los datos de su propia tarea. Pero, como se señaló explícitamente al cerrar la última lección, todo ese trabajo se ha hecho sobre HTML sin ningún estilo visual: sin bordes, sin tipografía cuidada, sin ningún color que distinga una tarjeta urgente de una que no lo es. Esta lección empieza a resolver esa carencia explicando primero un rasgo fundamental de los Web Components —la encapsulación de estilos que proporciona el Shadow DOM— y aplicándolo de inmediato para dar a <task-card> su primera hoja de estilos propia con static styles y la función css.

Contenido

  1. Qué añade el Shadow DOM al CSS: dos fronteras, no una
  2. static styles y la función css: la forma de escribir CSS en Lit
  3. Por qué no funciona un <link> o un <style> normal dentro del shadow root
  4. Alternativas para CSS realmente externo: adoptedStyleSheets
  5. Los primeros estilos propios de <task-card>
  6. El selector especial :host

  1. Qué añade el Shadow DOM al CSS: dos fronteras, no una

Antes de escribir una sola línea de CSS para <task-card>, conviene entender con precisión qué hace el Shadow DOM con los estilos, porque es un comportamiento distinto al de cualquier elemento HTML normal y es la base de todo lo que se construye a partir de esta lección.

Cuando un elemento no usa Shadow DOM —un <div>, un <article> cualquiera de una página normal—, el CSS de la página se aplica sobre él sin ninguna barrera: cualquier regla de cualquier hoja de estilos cargada en el documento puede afectarle, y cualquier estilo que ese elemento defina (por ejemplo, mediante una clase) puede afectar a otros elementos si el selector es lo bastante amplio. Es exactamente el motivo por el que, en proyectos grandes sin Web Components, es habitual acabar con convenciones de nomenclatura elaboradas (BEM y similares) solo para evitar que el CSS de una parte de la aplicación "se cuele" en otra.

El Shadow DOM cambia esto de raíz, levantando dos fronteras simultáneas:

  • El CSS de fuera no entra: las hojas de estilos globales del documento, los <link> externos, cualquier regla p { color: red; } escrita en el CSS general de la página, no afectan a los elementos que viven dentro del shadow root de <task-card>. Por dentro, <task-card> empieza siempre desde una hoja en blanco, salvo por las propiedades CSS heredables por naturaleza (como font-family o color, que sí atraviesan la frontera por herencia normal de CSS, no por ninguna característica especial de Lit) y por las variables CSS personalizadas, que se tratan en la siguiente lección de este módulo.
  • El CSS de dentro no sale: cualquier regla que se escriba dentro del shadow root de <task-card> —por ejemplo, article { border: 1px solid; }— solo afecta a los elementos <article> que viven dentro de ese shadow root concreto. No se filtra hacia el documento principal ni hacia otros componentes, ni siquiera hacia otras instancias de <task-card> con su propio shadow root independiente.

Esta doble frontera es la razón de fondo por la que los Web Components resuelven, de forma nativa y sin ninguna convención manual, el problema de colisión de nombres de clases CSS que arrastra el desarrollo web desde hace años. Se puede escribir con total libertad una regla como .detalle { padding: 1rem; } dentro de <task-card> sin preocuparse en absoluto de si existe otra clase .detalle en cualquier otra parte del sitio: viven en mundos de estilos completamente separados.

  1. static styles y la función css: la forma de escribir CSS en Lit

Lit ofrece un mecanismo dedicado para declarar el CSS de un componente: un campo estático llamado styles, análogo en espíritu a static properties visto en el módulo anterior, cuyo valor se construye con una función especial llamada css, importada también de lit.

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

class TaskCard extends LitElement {
  static styles = css`
    article {
      border: 1px solid #ccc;
      padding: 1rem;
    }
  `;

  render() {
    return html`<article><h3>Ejemplo</h3></article>`;
  }
}

Fíjate en el paralelismo con html, ya conocido desde el módulo 2: css, igual que html, es una función que se usa como tagged template (una plantilla de JavaScript precedida directamente por el nombre de la función, sin paréntesis) y devuelve un valor especial que Lit sabe interpretar; no es una simple cadena de texto, aunque el contenido entre los backticks se escriba exactamente igual que en un fichero .css normal. Esta similitud de sintaxis entre html y css no es casualidad: ambas forman parte del mismo sistema de plantillas etiquetadas de Lit, pensado para que el editor de código pueda ofrecer resaltado de sintaxis (con las extensiones adecuadas) tanto para el marcado como para los estilos, directamente dentro del fichero JavaScript.

static styles se declara, igual que static properties, una única vez, fuera de cualquier método, como campo estático de la clase. Lit la procesa cuando se define el componente y, en tiempo de ejecución, inserta ese CSS dentro del propio shadow root de cada instancia, de forma que las dos fronteras descritas en el apartado anterior se apliquen automáticamente sin ningún trabajo adicional por tu parte.

  1. Por qué no funciona un <link> o un <style> normal dentro del shadow root

Es razonable preguntarse, sobre todo si se viene de escribir HTML y CSS de forma tradicional, por qué no basta con incluir una etiqueta <style> (o un <link rel="stylesheet"> apuntando a un fichero .css) directamente dentro de la plantilla render(), en lugar de usar static styles.

// Funciona, pero con un problema de rendimiento importante
render() {
  return html`
    <style>
      article { border: 1px solid #ccc; padding: 1rem; }
    </style>
    <article><h3>Ejemplo</h3></article>
  `;
}

Técnicamente, una etiqueta <style> escrita así dentro de render() sí queda encapsulada por el Shadow DOM de la misma manera que el resto del contenido: sus reglas solo afectarán a este shadow root. El problema no es de encapsulación, sino de rendimiento: como se explicó en la lección del ciclo de renderizado del módulo 2, render() se vuelve a ejecutar cada vez que cambia una propiedad reactiva, y con este planteamiento la etiqueta <style> completa —y, en muchos navegadores, el CSS que contiene— se recrearía y se volvería a procesar en cada una de esas actualizaciones, un coste completamente innecesario para un CSS que casi siempre es el mismo en cada renderizado.

static styles, en cambio, se procesa una sola vez por definición de clase (no por instancia, ni por renderizado): Lit construye internamente una representación optimizada del CSS y la reutiliza en todas las actualizaciones e, incluso, en todas las instancias del mismo componente que coexistan en la página. Es, con diferencia, la forma recomendada de declarar estilos en Lit, y la única que se usa en el resto de este curso.

En cuanto a un <link rel="stylesheet" href="..."> normal apuntando a un fichero .css externo, colocado dentro del render(): en la práctica funciona en navegadores modernos, pero tiene un problema añadido de tiempo: el shadow root se renderiza antes de que el navegador termine de descargar la hoja de estilos externa, así que se produce casi siempre un parpadeo visible de contenido sin estilo (el fenómeno conocido como FOUC, flash of unstyled content) durante el primer renderizado, cada vez que se crea una nueva instancia del componente. Por este motivo, y por la ventaja de rendimiento ya explicada, static styles con css es la vía recomendada por la propia documentación de Lit para el caso normal de un componente con su CSS conocido de antemano.

  1. Alternativas para CSS realmente externo: adoptedStyleSheets

Existe un escenario legítimo en el que sí interesa cargar CSS desde una fuente verdaderamente externa al propio módulo del componente: por ejemplo, una hoja de estilos generada dinámicamente, o compartida mediante una API de bajo nivel del navegador llamada adoptedStyleSheets, que permite construir un objeto CSSStyleSheet mediante JavaScript y "adoptarlo" en uno o varios shadow roots sin necesidad de que cada uno tenga su propia copia del CSS en memoria.

Lit usa precisamente adoptedStyleSheets por debajo, de forma transparente, cuando el navegador lo soporta (con una alternativa automática para los pocos navegadores que no lo hacen), como mecanismo de implementación de static styles. No es necesario, en el uso normal de Lit, tocar adoptedStyleSheets directamente: se menciona aquí únicamente para que quede claro que static styles no es una limitación del framework, sino una capa cómoda construida sobre una API estándar de la plataforma web, pensada exactamente para este propósito de compartir CSS entre shadow roots de forma eficiente. La siguiente lección de este módulo, "Estilos Compartidos entre Componentes", retoma esta idea de compartir CSS, pero trabajando siempre al nivel de static styles y css, que es la vía práctica del día a día con Lit.

  1. Los primeros estilos propios de <task-card>

Con la teoría ya cubierta, es el momento de dar a <task-card> su primera apariencia visual real. Retoma el fichero src/components/task-card.js tal como quedó al final del módulo 3 —con sus propiedades reactivas, su estado interno expandida y su insignia de estado— y añade static styles:

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

const conversorDeFecha = {
  fromAttribute(valorDelAtributo) {
    if (!valorDelAtributo) {
      return null;
    }
    return new Date(valorDelAtributo);
  },
  toAttribute(valorDeLaPropiedad) {
    if (!valorDeLaPropiedad) {
      return null;
    }
    return valorDeLaPropiedad.toISOString().split('T')[0];
  },
};

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

  static styles = css`
    article {
      border: 1px solid #d0d5dd;
      border-radius: 8px;
      padding: 1rem;
      margin-bottom: 0.75rem;
      font-family: system-ui, sans-serif;
      background-color: #ffffff;
    }

    h3 {
      margin: 0 0 0.5rem 0;
      font-size: 1.1rem;
      color: #1f2933;
    }

    p {
      margin: 0.25rem 0;
      font-size: 0.9rem;
      color: #52606d;
    }

    .insignia {
      display: inline-block;
      padding: 0.15rem 0.5rem;
      border-radius: 999px;
      font-size: 0.8rem;
      margin-bottom: 0.5rem;
    }

    .aviso {
      color: #b42318;
      font-weight: bold;
    }
  `;

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

  alternarExpandida() {
    this.expandida = !this.expandida;
  }

  renderInsigniaEstado() {
    if (this.estado === 'hecha') {
      return html`<span class="insignia insignia--hecha">✓ Hecha</span>`;
    }
    if (this.estado === 'en-progreso') {
      return html`<span class="insignia insignia--progreso">◐ En progreso</span>`;
    }
    return html`<span class="insignia insignia--pendiente">○ Pendiente</span>`;
  }

  renderFechaLimite() {
    if (!this.fechaLimite) {
      return '';
    }
    return html`<p>Fecha límite: ${this.fechaLimite.toLocaleDateString('es-ES')}</p>`;
  }

  render() {
    return html`
      <article @click="${this.alternarExpandida}">
        <h3>${this.titulo}</h3>
        ${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);

El bloque static styles contiene reglas CSS completamente normales —selectores de etiqueta (article, h3, p) y de clase (.insignia, .aviso)—, exactamente igual que en cualquier hoja de estilos tradicional. Lo único distinto es dónde vive ese CSS y a qué afecta: como se explicó en el apartado 1, la regla article { border: 1px solid #d0d5dd; ... } solo puede afectar al <article> que la propia plantilla de <task-card> genera, sin riesgo de chocar con ningún otro <article> que pudiera existir en cualquier otra parte de TaskFlow (por ejemplo, dentro de <task-list>, que en la siguiente lección de este módulo también usará su propio elemento de estructura). Nótese, además, que las clases .insignia--hecha, .insignia--progreso y .insignia--pendiente, aunque generadas por renderInsigniaEstado(), no tienen todavía ninguna regla propia en este primer paso: se retoman con sus colores concretos en la lección "Variables CSS Personalizadas y Theming" de este mismo módulo.

  1. El selector especial :host

Antes de cerrar esta primera lección, conviene presentar un selector que no existe en el CSS tradicional y que resultará imprescindible en el resto del módulo: :host. Dentro de una hoja de estilos declarada con static styles, :host selecciona el propio elemento personalizado, es decir, la etiqueta <task-card> tal como se ve desde fuera del shadow root, no ningún elemento de dentro de la plantilla.

static styles = css`
  :host {
    display: block;
  }

  article {
    border: 1px solid #d0d5dd;
    /* ... */
  }
`;

Esta regla :host { display: block; } es, de hecho, una práctica habitual y recomendada al empezar a dar estilo a cualquier componente Lit: por defecto, un elemento personalizado sin ningún CSS asociado se comporta como un elemento inline (igual que un <span>), lo que puede producir comportamientos de maquetación poco intuitivos si se espera que ocupe todo el ancho disponible o que respete correctamente los márgenes; declarar :host { display: block; } hace que <task-card> se comporte, de cara al documento que lo contiene, como un bloque normal. :host es también la pieza clave para poder escribir selectores como :host([estado="hecha"]), mencionados en la última lección del módulo 3 a propósito de reflect: true, aunque ese uso concreto —con variables CSS y selección por atributo— se desarrolla con más detalle en las lecciones siguientes de este módulo.

Errores Comunes y Consejos

  • Esperar que una clase CSS global de la página afecte a un componente Lit: como se explicó en el apartado 1, el CSS externo al shadow root no entra en él bajo ninguna circunstancia (salvo herencia de propiedades heredables y variables CSS, que se ven en la siguiente lección). Si una regla escrita en una hoja de estilos general del sitio no parece aplicarse a <task-card>, la causa casi siempre es esta frontera, no un error de sintaxis.
  • Escribir el CSS con una etiqueta <style> dentro de render() "porque funciona": como se detalló en el apartado 3, esta técnica encapsula correctamente el CSS, pero lo reprocesa en cada actualización del componente, un coste innecesario que static styles evita por diseño. Salvo casos muy concretos de CSS generado dinámicamente, static styles es siempre la opción a preferir.
  • Olvidar :host { display: block; } y sorprenderse por el comportamiento de maquetación del componente: un elemento personalizado sin estilo propio se comporta como inline por defecto; si <task-card> parece no respetar el ancho o los márgenes esperados dentro de <task-list>, conviene revisar primero si se ha declarado un display explícito en :host.
  • Intentar usar un <link rel="stylesheet"> dentro del shadow root esperando que cargue de forma instantánea: como se apuntó en el apartado 3, un <link> externo introduce una espera de red que puede provocar un parpadeo de contenido sin estilo en cada nueva instancia del componente; para el CSS propio de un componente, conocido de antemano, static styles no tiene este problema porque el CSS ya viaja incluido en el propio módulo JavaScript.

Ejercicios

  1. Añade a la hoja de estilos de <task-card> una regla :host { display: block; } y comprueba, colocando dos o más <task-card> seguidas en una página de prueba sin ningún otro CSS externo, que cada una ocupa su propia línea en lugar de aparecer una junto a otra.
  2. Escribe, en una página HTML de prueba fuera del shadow root de <task-card>, una regla article { border: 5px solid red; } en una hoja de estilos normal del documento, y comprueba en el navegador que el borde rojo no afecta en absoluto al <article> interno de <task-card>. Explica con tus propias palabras, apoyándote en el apartado 1, por qué ocurre esto.
  3. Añade una nueva regla a static styles que dé a la clase .detalle (el bloque que aparece cuando expandida es true) un fondo gris claro y un padding propio, y comprueba que ese estilo solo se aplica quando la tarjeta está expandida, sin tener que tocar nada de la lógica de render().

Soluciones

static styles = css`
  :host {
    display: block;
  }

  article {
    border: 1px solid #d0d5dd;
    border-radius: 8px;
    padding: 1rem;
    margin-bottom: 0.75rem;
  }
`;

Sin :host { display: block; }, dos <task-card> seguidas en el HTML tenderían a colocarse en la misma línea, como ocurre con cualquier elemento inline (por ejemplo, dos <span> consecutivos); con la regla añadida, cada <task-card> pasa a comportarse como un bloque independiente y las tarjetas se apilan verticalmente, una por línea, tal como cabría esperar de una lista de tareas.

  1. El borde rojo no aparece en el <article> de dentro de <task-card> porque, como se explica en el apartado 1, el Shadow DOM levanta una frontera que impide que el CSS del documento exterior entre en el shadow root del componente. La regla article { border: 5px solid red; }, al estar definida fuera de ese shadow root, solo podría afectar a elementos <article> que vivan igualmente fuera de cualquier shadow root (o dentro de shadow roots que hayan declarado explícitamente esa misma regla), nunca al <article> interno de <task-card>, que solo obedece al CSS declarado en su propio static styles.

static styles = css`
  /* ...reglas anteriores... */

  .detalle {
    background-color: #f2f4f7;
    padding: 0.5rem;
    border-radius: 4px;
    margin-top: 0.5rem;
  }
`;

Como .detalle solo aparece en el HTML generado por render() cuando this.expandida es true (según la lógica ya existente del módulo 3), la regla de estilo se aplica automáticamente solo en ese momento, sin ninguna necesidad de tocar la condición del render(): el CSS describe cómo se ve un elemento cuando existe, y es la propia plantilla la que decide cuándo ese elemento existe.

Conclusión

En esta lección has entendido la doble frontera que levanta el Shadow DOM entre el CSS de un componente y el resto del documento, has aprendido a declarar estilos con static styles y la función css, y has visto por qué esta vía es preferible a una etiqueta <style> dentro de render() o a un <link> externo dentro del shadow root. Con todo esto, <task-card> tiene ya su primera apariencia visual propia: una tarjeta con borde, tipografía cuidada y un :host correctamente configurado como bloque.

Sin embargo, en cuanto se dé estilo también a <task-list> (que necesitará su propio contenedor, su propio título, quizá su propia tipografía base), aparecerá enseguida un problema práctico: mucho de ese CSS —colores base, tipografía, espaciados— tiene sentido compartirlo entre varios componentes, no repetirlo copiado y pegado en cada uno. Ese es exactamente el contenido de la siguiente lección, "Estilos Compartidos entre Componentes", donde aprenderás a extraer una hoja de estilos común reutilizable entre <task-card> y <task-list>.

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