Cómo compilar un componente de cambio de tema

Una descripción general fundamental de cómo crear un componente de interruptor de tema adaptable y accesible.

En esta publicación, quiero compartir mi forma de pensar sobre cómo compilar un componente de interruptor de tema oscuro y claro. Prueba la demostración.

Se aumentó el tamaño del botón Demo para facilitar la visibilidad.

Si prefieres ver un video, aquí tienes una versión de esta publicación en YouTube:

Descripción general

Un sitio web puede proporcionar parámetros de configuración para controlar el esquema de colores en lugar de depender por completo de la preferencia del sistema. Esto significa que los usuarios pueden navegar en un modo diferente al de sus preferencias del sistema. Por ejemplo, el sistema de un usuario tiene un tema claro, pero el usuario prefiere que el sitio web se muestre en el tema oscuro.

Existen varias consideraciones de ingeniería web cuando se compila esta función. Por ejemplo, el navegador debe conocer la preferencia lo antes posible para evitar que el color de la página parpadee, y el control debe sincronizarse primero con el sistema y, luego, permitir excepciones almacenadas del cliente.

El diagrama muestra una vista previa de la carga de la página de JavaScript y los eventos de interacción del documento para mostrar en general que hay 4 rutas para configurar el tema.

Marca

Se debe usar un <button> para el botón de activación, ya que, de esta manera, se beneficiarán de los eventos y las funciones de interacción que proporciona el navegador, como los eventos de clic y la capacidad de enfoque.

El botón

El botón necesita una clase para usarlo desde CSS y un ID para usarlo desde JavaScript. Además, como el contenido del botón es un ícono en lugar de texto, agrega un atributo title para proporcionar información sobre el propósito del botón. Por último, agrega un [aria-label] para mantener el estado del botón de ícono, de modo que los lectores de pantalla puedan compartir el estado del tema con las personas con discapacidad visual.

<button 
  class="theme-toggle" 
  id="theme-toggle" 
  title="Toggles light & dark" 
  aria-label="auto"
>
  …
</button>

aria-label y aria-live educados

Para indicar a los lectores de pantalla que se deben anunciar los cambios en aria-label, agrega aria-live="polite" al botón.

<button 
  class="theme-toggle" 
  id="theme-toggle" 
  title="Toggles light & dark" 
  aria-label="auto" 
  aria-live="polite"
>
  …
</button>

Esta adición de marcado indica a los lectores de pantalla que, en lugar de aria-live="assertive", le informen al usuario lo que cambió de forma educada. En el caso de este botón, anunciará "claro" o "oscuro" según en qué se haya convertido aria-label.

El ícono de gráfico vectorial escalable (SVG)

SVG proporciona una forma de crear formas escalables de alta calidad con un marcado mínimo. La interacción con el botón puede activar nuevos estados visuales para los vectores, lo que hace que SVG sea ideal para los íconos.

El siguiente lenguaje de marcado SVG va dentro de <button>:

<svg class="sun-and-moon" aria-hidden="true" width="24" height="24" viewBox="0 0 24 24">
  …
</svg>

Se agregó aria-hidden al elemento SVG para que los lectores de pantalla sepan que deben ignorarlo, ya que está marcado como elemento de presentación. Esto es muy útil para las decoraciones visuales, como el ícono dentro de un botón. Además del atributo viewBox obligatorio en el elemento, agrega altura y ancho por motivos similares a los que las imágenes deben tener tamaños intercalados.

El Sol

El ícono de sol que se muestra con los rayos del sol atenuados y una flecha rosa fuerte que apunta al círculo en el centro.

El gráfico del sol consta de un círculo y líneas para las que SVG tiene formas convenientes. Para centrar el <circle>, se establecen las propiedades cx y cy en 12, que es la mitad del tamaño de la vista del puerto (24) y, luego, se le asigna un radio (r) de 6, que establece el tamaño.

<svg class="sun-and-moon" aria-hidden="true" width="24" height="24" viewBox="0 0 24 24">
  <circle class="sun" cx="12" cy="12" r="6" mask="url(#moon-mask)" fill="currentColor" />
</svg>

Además, la propiedad de máscara apunta a un ID del elemento SVG, que crearás a continuación, y, por último, se le asigna un color de relleno que coincide con el color de texto de la página con currentColor.

Los rayos del sol

El ícono de sol que se muestra con el centro del sol atenuado y una flecha rosa intenso que apunta a los rayos del sol.

A continuación, las líneas de los rayos de sol se agregan justo debajo del círculo, dentro de un grupo de elementos <g>.

<svg class="sun-and-moon" aria-hidden="true" width="24" height="24" viewBox="0 0 24 24">
  <circle class="sun" cx="12" cy="12" r="6" mask="url(#moon-mask)" fill="currentColor" />
  <g class="sun-beams" stroke="currentColor">
    <line x1="12" y1="1" x2="12" y2="3" />
    <line x1="12" y1="21" x2="12" y2="23" />
    <line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
    <line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
    <line x1="1" y1="12" x2="3" y2="12" />
    <line x1="21" y1="12" x2="23" y2="12" />
    <line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
    <line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
  </g>
</svg>

Esta vez, en lugar de que el valor de fill sea currentColor, se establece el stroke de cada línea. Las líneas y las formas de círculo crean un lindo sol con rayos.

La luna

Para crear la ilusión de una transición fluida entre la luz (sol) y la oscuridad (luna), la luna es una ampliación del ícono del sol con una máscara SVG.

<svg class="sun-and-moon" aria-hidden="true" width="24" height="24" viewBox="0 0 24 24">
  <circle class="sun" cx="12" cy="12" r="6" mask="url(#moon-mask)" fill="currentColor" />
  <g class="sun-beams" stroke="currentColor">
    …
  </g>
  <mask class="moon" id="moon-mask">
    <rect x="0" y="0" width="100%" height="100%" fill="white" />
    <circle cx="24" cy="10" r="6" fill="black" />
  </mask>
</svg>
Gráfico con tres capas verticales para mostrar cómo funciona el enmascaramiento. La capa superior es un cuadrado blanco con un círculo negro. La capa del medio es el ícono de sol.
La capa inferior está etiquetada como el resultado y muestra el ícono del sol con un corte donde está el círculo negro de la capa superior.

Las máscaras con SVG son potentes, ya que permiten que los colores blanco y negro quiten o incluyan partes de otro elemento gráfico. El ícono del sol se eclipsará con una forma de luna <circle> con una máscara SVG, simplemente moviendo una forma de círculo dentro y fuera de un área de máscara.

¿Qué sucede si no se carga el CSS?

Captura de pantalla de un botón de navegador simple con el ícono de sol dentro.

Puede ser conveniente probar tu SVG como si el CSS no se hubiera cargado para asegurarte de que el resultado no sea demasiado grande o no cause problemas de diseño. Los atributos de altura y ancho intercalados en el SVG, además del uso de currentColor, proporcionan reglas de estilo mínimas para que el navegador use si no se carga el CSS. Esto permite crear buenos estilos defensivos contra la turbulencia de la red.

Diseño

El componente del interruptor de temas tiene una pequeña área de superficie, por lo que no necesitas una cuadrícula ni un flexbox para el diseño. En su lugar, se usan el posicionamiento SVG y las transformaciones CSS.

Estilos

Estilos de .theme-toggle

El elemento <button> es el contenedor de las formas y los estilos de los íconos. Este contexto superior contendrá colores y tamaños adaptables para pasarlos al SVG.

La primera tarea es hacer que el botón sea un círculo y quitar los estilos de botón predeterminada:

.theme-toggle {
  --size: 2rem;
  
  background: none;
  border: none;
  padding: 0;

  inline-size: var(--size);
  block-size: var(--size);
  aspect-ratio: 1;
  border-radius: 50%;
}

A continuación, agrega algunos estilos de interacción. Agrega un estilo de cursor para los usuarios del mouse. Agrega touch-action: manipulation para obtener una experiencia táctil de reacción rápida. Quita el resaltado semitransparente que iOS aplica a los botones. Por último, dale al contorno del estado de enfoque un poco de espacio libre desde el borde del elemento:

.theme-toggle {
  --size: 2rem;

  background: none;
  border: none;
  padding: 0;

  inline-size: var(--size);
  block-size: var(--size);
  aspect-ratio: 1;
  border-radius: 50%;

  cursor: pointer;
  touch-action: manipulation;
  -webkit-tap-highlight-color: transparent;
  outline-offset: 5px;
}

El SVG dentro del botón también necesita algunos estilos. El SVG debe adaptarse al tamaño del botón y, para suavizar la apariencia, redondear los extremos de la línea:

.theme-toggle {
  --size: 2rem;

  background: none;
  border: none;
  padding: 0;

  inline-size: var(--size);
  block-size: var(--size);
  aspect-ratio: 1;
  border-radius: 50%;

  cursor: pointer;
  touch-action: manipulation;
  -webkit-tap-highlight-color: transparent;
  outline-offset: 5px;

  & > svg {
    inline-size: 100%;
    block-size: 100%;
    stroke-linecap: round;
  }
}

Tamaño adaptable con la consulta de medios hover

El tamaño del botón del ícono es un poco pequeño en 2rem, lo cual es adecuado para los usuarios de mouse, pero puede ser un problema para un puntero grueso, como un dedo. Haz que el botón cumpla con muchos lineamientos de tamaño de toque con una búsqueda de contenido multimedia de desplazamiento para especificar un aumento de tamaño.

.theme-toggle {
  --size: 2rem;
  
  
  @media (hover: none) {
    --size: 48px;
  }
}

Estilos de SVG de sol y luna

El botón contiene los aspectos interactivos del componente del interruptor de temas, mientras que el SVG dentro contendrá los aspectos visuales y animados. Aquí es donde se puede hacer que el ícono sea precioso y cobre vida.

Tema claro

ALT_TEXT_HERE

Para que las animaciones de escala y rotación se produzcan desde el centro de las formas SVG, establece su transform-origin: center center. Las formas usan aquí los colores adaptables que proporciona el botón. La luna y el sol usan el botón proporcionado var(--icon-fill) y var(--icon-fill-hover) para su relleno, mientras que los rayos de sol usan las variables para el trazo.

.sun-and-moon {
  & > :is(.moon, .sun, .sun-beams) {
    transform-origin: center center;
  }

  & > :is(.moon, .sun) {
    fill: var(--icon-fill);

    @nest .theme-toggle:is(:hover, :focus-visible) > & {
      fill: var(--icon-fill-hover);
    }
  }

  & > .sun-beams {
    stroke: var(--icon-fill);
    stroke-width: 2px;

    @nest .theme-toggle:is(:hover, :focus-visible) & {
      stroke: var(--icon-fill-hover);
    }
  }
}

Tema oscuro

ALT_TEXT_HERE

Los estilos de luna deben quitar los rayos de sol, aumentar el tamaño del círculo del sol y mover la máscara del círculo.

.sun-and-moon {
  @nest [data-theme="dark"] & {
    & > .sun {
      transform: scale(1.75);
    }

    & > .sun-beams {
      opacity: 0;
    }

    & > .moon > circle {
      transform: translateX(-7px);

      @supports (cx: 1px) {
        transform: translateX(0);
        cx: 17px;
      }
    }
  }
}

Observa que el tema oscuro no tiene cambios ni transiciones de color. El componente de botón superior es propietario de los colores, que ya son adaptables en un contexto oscuro y claro. La información de transición debe estar detrás de la consulta de medios de preferencia de movimiento del usuario.

Animación

En este punto, el botón debe ser funcional y con estado, pero sin transiciones. En las siguientes secciones, se define cómo y qué transiciones.

Cómo compartir consultas de medios y cómo importar suavizaciones

Para facilitar la incorporación de transiciones y animaciones detrás de las preferencias de movimiento del sistema operativo de un usuario, el complemento PostCSS Custom Media habilita el uso de la especificación de CSS redactada para la sintaxis de las variables de consulta de medios:

@custom-media --motionOK (prefers-reduced-motion: no-preference);

/* usage example */
@media (--motionOK) {
  .sun {
    transition: transform .5s var(--ease-elastic-3);
  }
}

Para obtener suavizaciones de CSS únicas y fáciles de usar, importa la parte de suavizaciones de Open Props:

@import "https://unpkg.com/open-props/easings.min.css";

/* usage example */
.sun {
  transition: transform .5s var(--ease-elastic-3);
}

El Sol

Las transiciones del sol serán más lúdicas que las de la luna, y se logrará este efecto con suavizaciones rebotantes. Los rayos de sol deben rebotar un poco a medida que rotan, y el centro del sol debe rebotar un poco a medida que se escala.

Los estilos predeterminados (tema claro) definen las transiciones, y los estilos del tema oscuro definen las personalizaciones para la transición al tema claro:

​​.sun-and-moon {
  @media (--motionOK) {
    & > .sun {
      transition: transform .5s var(--ease-elastic-3);
    }

    & > .sun-beams {
      transition: 
        transform .5s var(--ease-elastic-4),
        opacity .5s var(--ease-3)
      ;
    }

    @nest [data-theme="dark"] & {
      & > .sun {
        transform: scale(1.75);
        transition-timing-function: var(--ease-3);
        transition-duration: .25s;
      }

      & > .sun-beams {
        transform: rotateZ(-25deg);
        transition-duration: .15s;
      }
    }
  }
}

En el panel Animation de Chrome DevTools, puedes encontrar un cronograma de las transiciones de animación. Se puede inspeccionar la duración de la animación total, los elementos y el tiempo de suavización.

La transición de claro a oscuro
La transición de oscuro a claro

La luna

Las posiciones de luz de luna y oscuridad ya están configuradas. Agrega estilos de transición dentro de la consulta de medios --motionOK para darle vida y, al mismo tiempo, respetar las preferencias de movimiento del usuario.

El tiempo con retraso y duración son fundamentales para que esta transición sea fluida. Si el sol se eclipsa demasiado pronto, por ejemplo, la transición no se siente orquestada o lúdica, sino caótica.

​​.sun-and-moon {
  @media (--motionOK) {
    & .moon > circle {
      transform: translateX(-7px);
      transition: transform .25s var(--ease-out-5);

      @supports (cx: 1px) {
        transform: translateX(0);
        cx: 17px;
        transition: cx .25s var(--ease-out-5);
      }
    }

    @nest [data-theme="dark"] & {
      & > .moon > circle {
        transition-delay: .25s;
        transition-duration: .5s;
      }
    }
  }
}
La transición de claro a oscuro
La transición de oscuro a claro

Prefiere el movimiento reducido

En la mayoría de los desafíos de GUI, trato de mantener algunas animaciones, como las transiciones de opacidad cruzada, para los usuarios que prefieren reducir el movimiento. Sin embargo, este componente se veía mejor con cambios de estado instantáneos.

JavaScript

Hay mucho trabajo para JavaScript en este componente, desde la administración de información de ARIA para lectores de pantalla hasta la obtención y configuración de valores desde el almacenamiento local.

La experiencia de carga de la página

Era importante que no se produjeran destellos de color durante la carga de la página. Si un usuario con un esquema de colores oscuros indica que prefiere la luz con este componente y, luego, vuelve a cargar la página, al principio, la página estará oscura y, luego, parpadeará a la luz. Para evitar esto, se ejecutó una pequeña cantidad de bloqueo de JavaScript con el objetivo de establecer el atributo HTML data-theme lo antes posible.

<script src="./theme-toggle.js"></script>

Para lograrlo, primero se carga una etiqueta <script> sin formato en el documento <head>, antes de cualquier lenguaje de marcado CSS o <body>. Cuando el navegador encuentra una secuencia de comandos sin marcar como esta, ejecuta el código y lo ejecuta antes que el resto del código HTML. Si usas este momento de bloqueo con moderación, es posible configurar el atributo HTML antes de que el CSS principal pinte la página, lo que evita que se produzcan destellos o colores.

En primer lugar, JavaScript verifica la preferencia del usuario en el almacenamiento local y, luego, recurre a la preferencia del sistema si no se encuentra nada en el almacenamiento:

const storageKey = 'theme-preference'

const getColorPreference = () => {
  if (localStorage.getItem(storageKey))
    return localStorage.getItem(storageKey)
  else
    return window.matchMedia('(prefers-color-scheme: dark)').matches
      ? 'dark'
      : 'light'
}

A continuación, se analiza una función para establecer la preferencia del usuario en el almacenamiento local:

const setPreference = () => {
  localStorage.setItem(storageKey, theme.value)
  reflectPreference()
}

Seguido de una función para modificar el documento con las preferencias.

const reflectPreference = () => {
  document.firstElementChild
    .setAttribute('data-theme', theme.value)

  document
    .querySelector('#theme-toggle')
    ?.setAttribute('aria-label', theme.value)
}

Un aspecto importante que debes tener en cuenta en este punto es el estado de análisis del documento HTML. El navegador aún no conoce el botón "#theme-toggle", ya que la etiqueta <head> no se analizó por completo. Sin embargo, el navegador tiene un document.firstElementChild, también conocido como la etiqueta <html>. La función intenta configurar ambas para mantenerlas sincronizadas, pero en la primera ejecución solo podrá configurar la etiqueta HTML. Al principio, querySelector no encontrará nada, y el operador de encadenamiento opcional garantiza que no haya errores de sintaxis cuando no se encuentre y se intente invocar la función setAttribute.

A continuación, se llama inmediatamente a esa función reflectPreference() para que el documento HTML tenga su atributo data-theme establecido:

reflectPreference()

El botón aún necesita el atributo, así que espera al evento de carga de la página. Luego, será seguro consultar, agregar objetos de escucha y establecer atributos en:

window.onload = () => {
  // set on load so screen readers can get the latest value on the button
  reflectPreference()

  // now this script can find and listen for clicks on the control
  document
    .querySelector('#theme-toggle')
    .addEventListener('click', onClick)
}

La experiencia de activación

Cuando se hace clic en el botón, se debe intercambiar el tema en la memoria de JavaScript y en el documento. Se deberá inspeccionar el valor del tema actual y tomar una decisión sobre su estado nuevo. Una vez que se establezca el estado nuevo, guárdalo y actualiza el documento:

const onClick = () => {
  theme.value = theme.value === 'light'
    ? 'dark'
    : 'light'

  setPreference()
}

Sincronización con el sistema

Lo único que tiene este cambio de tema es la sincronización con la preferencia del sistema a medida que cambia. Si un usuario cambia su preferencia del sistema mientras una página y este componente están visibles, el interruptor de temas cambiará para coincidir con la nueva preferencia del usuario, como si el usuario hubiera interactuado con el interruptor de temas al mismo tiempo que cambiaba el sistema.

Para lograrlo, usa JavaScript y un evento matchMedia que detecte cambios en una consulta de contenido multimedia:

window
  .matchMedia('(prefers-color-scheme: dark)')
  .addEventListener('change', ({matches:isDark}) => {
    theme.value = isDark ? 'dark' : 'light'
    setPreference()
  })
Cambiar la preferencia del sistema de macOS cambia el estado del interruptor de temas.

Conclusión

Ahora que sabes cómo lo hice, ¿cómo lo harías tú? 🙂

Diversifiquemos nuestros enfoques y aprendamos todas las formas de compilar en la Web. Crea una demo, twittea los vínculos y los agregaré a la sección de remixes de la comunidad a continuación.

Remixes de la comunidad