CSS en WebComponents

Ahora que ya sabemos crear componentes nativos (WebComponents), convendría conocer las diferentes formas que tenemos a nuestra disposición de manejar los estilos CSS en el código de nuestro componente, ya sea con o sin Shadow DOM tanto como desde dentro del componente (enfoque local) o desde fuera (enfoque global).

CSS en WebComponents

Por esta misma razón, es importante repasar conceptos como Shadow DOM, Light DOM o las partes de un WebComponent, si aún no lo tenemos claro antes de continuar.

Estilos globales

En primer lugar, hay que tener en cuenta que en esencia, un WebComponent no es más que un custom element con más características. Y a un custom element, no deja de ser un elemento HTML normal, por lo que se le puede dar estilo CSS.

Imaginemos el siguiente fragmento de código global, en el documento HTML (se utilizará formato compacto para simplificar). Hemos definido un componente sin Shadow DOM y le hemos aplicado estilos globalmente:

<app-element></app-element>

<style>
  /* CSS Global */
  app-element {
    display: inline-block;
    padding: 6px 20px;
    background: steelblue;
    color: white;
  }
  app-element span {
    font-weight: bold;
    vertical-align: super;
    font-size: small;
    color: gold;
  }
</style>

<script>
  customElements.define("app-element", class extends HTMLElement {
    connectedCallback() {
      this.innerHTML = `<div class="element">AppElement <span>New!</span></div>`;
    }
  });
</script>

Como podemos observar, podemos sacar dos conclusiones importantes:

Método o estrategia Sin Shadow DOM Con Shadow DOM
¿Afecta el CSS global a un custom element? ✔️ Afecta ✔️ Afecta
¿Afecta el CSS global a elementos del interior de un custom element? ✔️ Afecta ❌ No Afecta
  • Es posible dar estilo al propio componente desde el CSS global del documento (desde fuera del componente), tenga o no tenga Shadow DOM.

  • Es posible dar estilo de forma global a elementos del interior de un componente, siempre que no haya Shadow DOM que «proteja» el componente.

Por su parte, la especificidad (global vs local) funciona como siempre, respetando los criterios a los que estamos acostumbrados.

Estilos en línea en el componente

Sin embargo, ya que nos encontramos aislando el HTML (marcado) y el Javascript (lógica) del componente en su interior, estaría bien contener los estilos en él, modularizando y organizando todo mejor.

Por eso, la estrategia preferida para añadir estilos en un componente suele ser utilizar la etiqueta <style> en el marcado HTML del componente. En ella podremos escribir los estilos CSS que afectarán a toda la página (global) o sólo al componente (local), dependiendo de si creamos y adjuntamos un Shadow DOM o no.

Adaptemos el ejemplo anterior a este caso, en esta ocasión sin Shadow DOM:

<app-element></app-element>

<script>
  customElements.define("app-element", class extends HTMLElement {
    connectedCallback() {
      this.innerHTML = `
        <style>
          /* CSS Local */
          .element {
            display: inline-block;
            padding: 6px 20px;
            background: steelblue;
            color: white;
          }
          span {
            font-weight: bold;
            vertical-align: super;
            font-size: small;
            color: gold;
          }
        </style>
        <div class="element">
          AppElement <span>New!</span>
        </div>
      `;
    }
  });
</script>

Nota: Hay un pequeño matiz respecto al ejemplo del apartado anterior. En este último caso estamos dando estilo al elemento con clase .element, mientras que en el ejemplo anterior lo hacíamos al propio componente <app-element>. Más adelante veremos como podemos hacer eso desde dentro del componente.

En este caso, las conclusiones que podemos sacar son las siguientes:

Método o estrategia Sin Shadow DOM Con Shadow DOM
¿Afecta el CSS del componente al CSS global? ✔️ Afecta ❌ No Afecta
¿Afecta el CSS global al CSS del componente? ✔️ Afecta ❌ No Afecta
  • Si el componente no tiene Shadow DOM, los CSS actuan de forma global de modo que el CSS definido en las etiquetas <style> afectará al resto de la página y otros componentes. De la misma forma, el CSS del resto de la página afectará al del componente.

  • Si el componente tiene Shadow DOM, encapsula los estilos, protegiendo el componente. No le afectará el CSS externo, ni el CSS del componente afectará al resto de la página.

Estilos externos al componente

Escribir estilos en línea es una magnífica forma de simplificar los componentes, modularizando y encapsulando elementos de una página, a la vez que los preparamos para hacer más reutilizables y entenderlos de un solo vistazo al componente.

Sin embargo, hay desarrolladores que no ven con buenos ojos el uso de las etiquetas <style> o el hecho de añadir marcado dentro de template strings, que de ser demasiado extenso, complicaría mucho el archivo Javascript en el que estamos.

Si lo prefieres, una forma alternativa sería la de utilizar uno de los siguientes enfoques, donde tendríamos un archivo .css separado de nuestro componente .js:

El primer enfoque lo podríamos denominar la versión desde HTML, ya que sería utilizando la etiqueta HTML link, con la que enlazamos una archivo de hoja de estilos .css externo, donde estarán los estilos del componente que antes teníamos en el interior de la etiqueta <style>:

<app-element></app-element>

<script>
  customElements.define("app-element", class extends HTMLElement {
    connectedCallback() {
      this.innerHTML = `
        <link rel="stylesheet" href="/components/AppElement.css">
        <div class="element">
          AppElement <span>New!</span>
        </div>
      `;
    }
  });
</script>

En este caso estaremos haciendo una petición adicional al fichero .css, pero tendremos los estilos separados en diferentes archivos, algo que muchos desarrolladores suelen preferir.

Nota: Ten en cuenta que en este caso, la ruta del archivo .css debe partir desde donde está el documento HTML principal, y no desde donde está el fichero del componente, por lo que cuidado al establecer rutas relativas.

Desde CSS (regla @import)

El segundo enfoque trata de utilizar la regla @import de CSS, por lo que hacemos casi lo mismo que en el apartado anterior, sólo que realizando la petición desde CSS, en el interior de la etiqueta <style>. Veamos un ejemplo:

<app-element></app-element>

<script>
  customElements.define("app-element", class extends HTMLElement {
    connectedCallback() {
      this.innerHTML = `
        <style>
          @import "/components/AppElement.css";
        </style>
        <div class="element">
          AppElement <span>New!</span>
        </div>
      `;
    }
  });
</script>
Desde JS (CSS Constructables)

Por último, un enfoque experimental (aún sólo disponible en Chrome) es el que se está llevando a cabo con los denominados CSS Constructables. Se trata de realizar prácticamente lo mismo que en los ejemplos anteriores, pero con un enfoque más «programático» y dinámico, desde Javascript:

<app-element></app-element>

<script>
  import css from "./AppElement.css";

  customElements.define("app-element", class extends HTMLElement {
    connectedCallback() {
      document.adoptedStyleSheets = [...document.adoptedStyleSheets, css];
      this.innerHTML = `
        <div class="element">
          AppElement <span>New!</span>
        </div>
      `;
    }
  });
</script>

Recuerda mucho al enfoque que se sigue en los diferentes frameworks Javascript, salvo con la diferencia que eliminamos herramientas intermediarias para hacer estas tareas (Node, Webpack, etc...), lo hace directamente el navegador, y permite cosas más potentes, al tener una API propia.

Utilizamos el import de ESM para cargar el fichero .css que tenemos en la misma carpeta del componente. Fíjense que en este caso si son rutas relativas al componente. Una vez cargado este fichero .css, su contenido se parseará y se genera un objeto CSSStyleSheet.

Posteriormente, podríamos utilizar .adoptedStyleSheets para adoptar ese CSS que acabamos de importar en el documento actual, o en el Shadow DOM del componente, si disponemos de él (en ese caso, de la siguiente forma):

this.shadowRoot.adoptedStyleSheets = [...document.adoptedStyleSheets, css];

Observa que la forma «programática» de adoptar los estilos importados es añadir al array .adoptedStyleSheets la desestructuración de los estilos adoptados actuales en el documento, más el CSS que acabamos de importar. No es posible hacer un .push() ya que este array no lo permite (no es extensible).

En el futuro, la idea es que podamos hacer un import tanto de archivos .css como de archivos .html o .json y tratarlos de forma nativa por el navegador a través de la especificación de HTML Modules y JSON Modules. Sin embargo, aún están en fase experimental.

Variables CSS (Custom Properties)

Otra forma alternativa que hemos pasado por alto es la de utilizar CSS Custom Properties (denominadas habitualmente variables CSS). Este tipo de variables se pueden definir desde nuestro documento global, tienen conceptos de ámbitos y son capaces de penetrar en el Shadow DOM de un elemento, por lo que son una estrategia perfecta para organizar nuestros estilos con ellas.

Veamos un ejemplo donde utilicemos CSS Custom Properties para dar estilo a los componentes:

<app-element></app-element>
<app-element></app-element>
<app-element></app-element>

<style>
  /* CSS Global */
  app-element:first-of-type {
    --color: orangered;
  }
</style>

<script>
  customElements.define("app-element", class extends HTMLElement {
    connectedCallback() {
      this.innerHTML = `
        <style>
          /* CSS Local */
          .element {
            display: inline-block;
            padding: 6px 20px;
            background: var(--color, steelblue);
            color: white;
          }
          span {
            font-weight: bold;
            vertical-align: super;
            font-size: small;
            color: gold;
          }
        </style>
        <div class="element">
          AppElement <span>New!</span>
        </div>
      `;
    }
  });
</script>

Como se puede observar, definimos tres instancias del mismo componente. Desde el CSS global de la página, indicamos que en el ámbito del primer componente tendrá una variable --color establecida a naranja. En el resto de componentes no existirá.

Por lo tanto, en el interior del componente definimos background: var(--color, steelblue), lo que significa que use el color de fondo --color y si esa variable no está definida, utilice steelblue.

Con esto conseguimos preparar nuestro componente para tener valores por defecto que se modificarán dependiendo de lo que indique el usuario que consume el componente.

CSS Scoping (Componentes)

Al introducir el concepto de componente, podemos necesitar realizar acciones de selección CSS especificas que antes no existían. Además, al trabajar con un componente debemos ser conscientes de que habitualmente tenemos un marcado HTML en su interior, pero el propio custom element ya es un elemento HTML contenedor en si mismo, que podemos aprovechar para simplificar nuestro marcado.

Además, esto es bastante importante cuando queremos Flexbox o Grid en nuestro componente, ya que son sistemas que funcionan aplicando CSS al padre directo.

En este apartado veremos tres pseudoclases interesantes que solo funcionan cuando tenemos definido un Shadow DOM:

Pseudoclase Descripción
:host Nos permite dar estilo al custom element, el propio contenedor del componente.
:host(css) Idem al anterior, pero sólo si coincide con el selector definido en css.
:host-context(css) Idem al anterior, pero sólo si tiene padres que coinciden con el selector css.

Para ilustrarlo lo mejor posible, vamos a ver un ejemplo donde utilizaremos cada una de estas pseudoclases. Observen que tenemos definidas tres instancias de componentes, y que el componente crea un Shadow DOM:

<app-element></app-element>
<app-element disabled></app-element>
<div class="box">
  <app-element></app-element>
</div>

<script>
  customElements.define("app-element", class extends HTMLElement {
    constructor() {
      super();
      this.attachShadow({ mode: "open" });
    }
    connectedCallback() {
      this.shadowRoot.innerHTML = `
        <style>
          :host {
            display: inline-block;
            padding: 6px 20px;
            background: steelblue;
            color: white;
          }
          :host([disabled]) {
            background: #aaa;
          }
          :host-context(.box) {
            background: red;
          }
          span {
            font-weight: bold;
            vertical-align: super;
            font-size: small;
            color: gold;
          }
        </style>
        <div class="element">
          AppElement <span>New!</span>
        </div>
      `;
    }
  });
</script>

En el caso de la pseudoclase :host, vemos que el componente busca el contenedor padre que contiene el Shadow DOM y le aplica los estilos definidos. Ten en cuenta que un custom element es un elemento inline, por lo que si intentas darle tamaño no te hará caso.

En el caso de la pseudoclase :host(), podemos indicar un selector CSS entre paréntesis, de modo que solo seleccionará los custom elements que encajen con dicho selector, útil para aplicar sólo a componentes con ciertas clases o atributos. En el ejemplo, le aplicamos un fondo gris a todo aquel componente que tenga el atributo disabled.

Por último, en el caso de la pseudoclase :host(), lo que hacemos es seleccionar los custom elements de aquellos componentes que se encuentren en un contexto que encaje con el selector especificado entre paréntesis. En nuestro ejemplo, aplicamos estilos al contenedor que se encuentre dentro de una clase .box.

CSS Parts

En el contexto de componentes, también surge una especificación (aún en fase experimental) para organizar y simplificar la forma en la que creamos partes de un componente. La idea clave de este sistema, es señalar claramente las partes de un componente, utilizando el atributo part con el nombre que deseemos.

El problema principal es que, en el caso de tener un Shadow DOM en nuestro componente, perdemos la posibilidad de dar estilo desde fuera del mismo, lo que puede ser un problema si queremos aplicar estilos para crear esquemas de colores o cosas similares donde los css custom elements se nos pueden quedar cortos.

Veamos como quedaría el connectedCallback() del ejemplo anterior, creando varias partes:

connectedCallback() {
  this.shadowRoot.innerHTML = `
    <style>
      :host {
        display: inline-block;
        padding: 6px 20px;
        background: grey;
        color: white;
      }
    </style>
    <div class="element">
      <span part="content">AppElement</span>
      <span part="badge">New!</span>
    </div>
  `;
}

Como se puede ver, tenemos un componente con fondo gris muy similar al de ejemplo anteriores. La diferencia es que hemos dividido en dos partes:

  • Una parte content para el contenido del componente.
  • Una parte badge para las decoraciones del componente.

Ahora, desde un enfoque de código CSS global, fuera del componente, podríamos utilizar el pseudoelemento ::part(name) para indicar las partes concretas de un componente a las que queremos dar estilo, incluso combinándolas con detalles explicados anteriormente, para ser más selectivo:

  app-element::part(content) {
    background: steelblue;
  }
  app-element::part(badge) {
    font-weight: bold;
    vertical-align: super;
    font-size: small;
    color: gold;
  }

Además, esta especificación también brinda una API Javascript, para poder manejar las partes desde la lógica de nuestro componente, permitiendo añadir, eliminar, reemplazar o conmutar a través de métodos como .add(), remove(), replace() o toggle(), o comprobar si existe mediante .contains(), similar a como se hace con la API classList de Javascript.

Ojo: Con ::part() no puedes darle estilo a elementos que se encuentren en el interior, sino que sólo sirve para elementos al mismo nivel.

Manz
Publicado por Manz

Docente, divulgador informático y freelance. Autor de Emezeta.com, es profesor en la Universidad de La Laguna y dirige el curso de Programación web FullStack y Diseño web FrontEnd de EOI en Tenerife (Canarias). En sus ratos libres, busca GIF de gatos en Internet.