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.
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 que no sea según 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 en el tema oscuro.
Hay 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.
Marca
Se debe usar un objeto <button>
para el botón de activación, ya que, luego, te beneficias de las funciones y los eventos de interacción que proporciona el navegador, como los eventos de clic y la enfocabilidad.
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 [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
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 se marca como presentacional. 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 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 al ID de un elemento SVG, que crearás a continuación y, por último, se le otorga un color de relleno que coincide con el color del texto de la página con currentColor
.
Los rayos del sol
A continuación, las líneas de rayos de sol se agregan justo debajo del círculo, dentro de un elemento 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 establece el trazo de cada línea. Las líneas más las formas de círculo crean un agradable 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>
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á por una forma de luna <circle>
con una máscara SVG, simplemente al mover una forma de círculo dentro y fuera del área de la máscara.
¿Qué sucede si no se carga el CSS?
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 crea bonitos estilos defensivos
contra la turbulencia de la red.
Diseño
El componente de cambio de tema tiene poca área de superficie, por lo que no necesitas cuadrícula ni 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 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 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, deja que el contorno del estado del enfoque tenga un respiro 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 el icono puede ser hermoso y darle vida.
Tema claro
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
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.
Compartir consultas de medios e importar velocidades
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 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;
}
}
}
}
Prefiere el movimiento reducido
En la mayoría de los desafíos de la GUI, trato de conservar alguna animación, como atenuaciones cruzadas de opacidad, para los usuarios que prefieren un movimiento reducido. 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 la 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 parpadeara el 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 ejecutaba 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 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 se usa este momento de bloqueo con moderación, se podrá establecer el atributo HTML antes de que el CSS principal pinte la página, y así se evitará la aparición de destello o de 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 actual del tema y tomar una decisión sobre su nuevo estado. 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
La sincronización con la preferencia del sistema a medida que cambia es exclusiva de este cambio de tema. 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()
})
Conclusión
Ahora que sabes cómo lo hice, ¿cómo lo harías?‽ 🙂
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.