Cómo compilar un componente de cambio de tema

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

En esta publicación, quiero compartir algunas ideas sobre cómo crear un componente para cambiar de tema oscuro y claro. Prueba la demostración.

Demostración para facilitar la visibilidad.

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

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 distinto de las preferencias del sistema. Por ejemplo, el sistema de un usuario tiene un tema claro, pero el usuario prefiere que el sitio web se muestre con el tema oscuro.

Hay varias consideraciones de ingeniería web para compilar esta función. Por ejemplo, el navegador debe conocer la preferencia lo antes posible para evitar que el color de la página destelle, y el control debe sincronizarse primero con el sistema y luego permitir las excepciones almacenadas en el cliente.

En el diagrama, se 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 de acceso para configurar el tema

Marca

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

El botón

El botón necesita una clase para usar con CSS y un ID para 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 objeto [aria-label] para mantener el estado del botón del í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 amables

Para indicar a los lectores de pantalla que se deben anunciar los cambios a 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 lenguaje de marcado le indica a los lectores de pantalla que, en lugar de aria-live="assertive", le informen al usuario qué cambió. En el caso de este botón, anunciará "claro" u "oscuro", según en qué se haya convertido en aria-label.

Ícono de gráfico vectorial escalable (SVG)

SVG proporciona una forma de crear formas escalables y de alta calidad con un lenguaje de marcado mínimo. Interactuar con el botón puede activar nuevos estados visuales para los vectores, lo que hace que los archivos SVG sean excelentes para los íconos.

El siguiente lenguaje de marcado SVG se incluye en <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 presentación. Esto es ideal para 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 por los que las imágenes deberían tener tamaños intercalados.

El Sol

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

El gráfico Sol consta de un círculo y líneas para las que el formato SVG convenientemente tiene formas. Para centrar la <circle>, configura las propiedades cx y cy en 12, que es la mitad del tamaño del viewport (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 al ID de un elemento SVG, que crearás a continuación, y, finalmente, se le asignará un color de relleno que coincida con el color del texto de la página con currentColor.

Los rayos de sol

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

A continuación, las líneas de rayos de sol se agregan justo debajo del círculo, dentro de un grupo de elementos de grupo <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 configura el trazo de cada línea. Las líneas más las formas circulares crean un lindo sol con rayos.

La Luna

Para crear la ilusión de una transición fluida entre la luz (sol) y oscura (luna), la luna es un aumento del ícono del sol mediante 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 que muestra 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 resultado y muestra el ícono de sol con un corte en el que está el círculo negro de la capa superior.

Las máscaras con SVG son potentes y permiten que los colores blanco y negro quiten o incluyan partes de otro gráfico. El ícono de sol será eclipsado por una forma de luna <circle> con una máscara SVG. Para ello, solo se debe mover una forma circular dentro y fuera del área de la máscara.

¿Qué sucede si no se carga el CSS?

Captura de pantalla de un botón del navegador simple con el ícono de sol en su interior.

Puede ser bueno probar el SVG como si la CSS no se cargara para asegurarte de que el resultado no sea demasiado grande ni cause problemas de diseño. Los atributos de altura y ancho intercalados en el SVG y el uso de currentColor brindan reglas de estilo mínimas que el navegador puede usar si no se carga CSS. Esto crea buenos estilos de defensa contra la turbulencia de red.

Diseño

El componente de cambio de tema tiene una superficie pequeña, por lo que no necesitas cuadrícula ni Flexbox para el diseño. En su lugar, se usan el posicionamiento SVG y las transformaciones de CSS.

Estilos

.theme-toggle estilos

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

La primera tarea es convertir el botón en un círculo y quitar los diseños de botón predeterminados:

.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 de mouse. Agrega touch-action: manipulation para obtener una experiencia táctil de reacción rápida. Quita el elemento semitransparente destacado que resalta que iOS se aplica a los botones. Por último, dale un poco de espacio al contorno del estado de enfoque 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 ajustarse al tamaño del botón y, para suavidad visual, redondea 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 está bien para los usuarios del mouse, pero puede resultar difícil para un puntero aproximado, como un dedo. Para que el botón cumpla con muchos lineamientos de tamaño táctil, usa una consulta de elementos multimedia con el cursor para especificar un aumento de tamaño.

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

Estilos SVG de sol y luna

El botón contiene los aspectos interactivos del componente de cambio de tema, mientras que el archivo SVG interno contiene los aspectos visuales y animados. Aquí es donde el icono se puede hacer hermoso y plasmar en 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. Aquí, las formas usan 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 diseños lunares deben quitar los rayos de sol, ampliar el círculo solar y mover la máscara circular.

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

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

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

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

Ten en cuenta que el tema oscuro no tiene cambios de color ni transiciones. El componente del botón superior posee los colores, y ya se pueden adaptar dentro de 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

El botón debe ser funcional y con estado, pero sin transiciones en este punto. En las siguientes secciones, se explica cómo definir las transiciones de cómo y qué.

Compartir consultas de medios e importar aceleraciones

Para facilitar la colocación de transiciones y animaciones detrás de las preferencias de movimiento del sistema operativo de un usuario, el complemento PostCSS Custom Media permite el uso de la sintaxis de CSS en borrador para 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 aceleraciones de CSS únicas y fáciles de usar, importa la parte perfeccionamientos 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 divertidas que la Luna, lo que logrará este efecto con aceleraciones alegres. 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 esta transición:

​​.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 las Herramientas para desarrolladores de Chrome, puedes encontrar un cronograma para las transiciones de animación. Se pueden inspeccionar la duración de la animación total, los elementos y el tiempo de aceleración.

Transición de luz a oscura
Transición de oscura a clara

La Luna

Las posiciones de luz de luna y oscura 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.

Los tiempos con demora y duración son fundamentales para que esta transición sea limpia. Si el sol se eclipsa demasiado temprano, por ejemplo, la transición no se siente orquestada ni divertida, se siente caótica.

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

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

    @nest [data-theme="dark"] & {
      & > .moon > circle {
        transition-delay: .25s;
        transition-duration: .5s;
      }
    }
  }
}
Transición de luz a oscura
Transición de oscura a clara

Prefiere los movimientos reducidos.

En la mayoría de los desafíos de la GUI, trato de mantener algo de animación, como las atenuaciones cruzadas de opacidad, para los usuarios que prefieren movimientos reducidos. Sin embargo, este componente se sentía mejor con cambios de estado instantáneos.

JavaScript

Hay mucho trabajo para JavaScript en este componente, desde administrar la información de ARIA para lectores de pantalla hasta obtener y configurar valores del almacenamiento local.

La experiencia de carga de la página

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

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

Para ello, 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 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 el flash o los colores.

JavaScript primero comprueba las preferencias del usuario en el almacenamiento local y el resguardo para verificar 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)
}

Algo importante para 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 una document.firstElementChild, también conocida como la etiqueta <html>. La función intenta configurar ambas para mantenerlas sincronizadas, pero, durante la primera ejecución, solo podrá configurar la etiqueta HTML. Al principio, querySelector no encontrará nada, y el operador de encadenamiento opcional garantizará que no se produzcan 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 establecido su atributo data-theme:

reflectPreference()

El botón aún necesita el atributo, así que espera el evento de carga de la página y, luego, será seguro consultar, agregar objetos de escucha y configurar atributos en las siguientes ubicaciones:

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 alternar

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

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

  setPreference()
}

Sincronizando con el sistema

La sincronización con las preferencias del sistema a medida que cambia es única para este cambio de tema. Si un usuario cambia su preferencia del sistema mientras una página y este componente está visible, el cambio de tema cambiará para coincidir con la preferencia del usuario nuevo, como si el usuario hubiera interactuado con el cambio de tema al mismo tiempo que se cambió el sistema.

Para lograrlo, usa JavaScript y un evento matchMedia que escuche los cambios en una consulta de medios:

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

Conclusión

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

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

Remixes de la comunidad