Descripción general básica de cómo crear un componente de cambio de tema adaptable y accesible.
En esta publicación, quiero compartir ideas sobre cómo crear un componente de cambio de tema claro y oscuro. Prueba la demostración.
Si prefieres un video, aquí tienes una versión de este artículo en YouTube:
Descripción general
Un sitio web puede proporcionar parámetros de configuración para controlar el esquema de color 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 puede estar en 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 crea esta función. Por ejemplo, el navegador debe conocer la preferencia lo antes posible para evitar destellos de color en la página, y el control debe sincronizarse primero con el sistema y, luego, permitir excepciones almacenadas del lado del cliente.

Marca
Se debe usar un <button>
para el botón de activación, ya que, de esta manera, se aprovechan los eventos y las funciones de interacción proporcionados por el navegador, como los eventos de clic y la capacidad de enfoque.
El botón
El botón necesita una clase para usarla desde CSS y un ID para usarlo desde JavaScript.
Además, dado que 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
cortés
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>
Este agregado de marcado indica a los lectores de pantalla que le digan al usuario qué cambió de forma cortés, en lugar de aria-live="assertive"
. En el caso de este botón, anunciará "claro" o "oscuro" según en qué se haya convertido el 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. Interactuar 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 marcó como de presentación. Esto es ideal para las decoraciones visuales, como el ícono dentro de un botón. Además del atributo viewBox
obligatorio en el elemento, agrega la altura y el ancho por motivos similares a los por 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 los que SVG tiene formas convenientes. El <circle>
se centra configurando las propiedades cx
y cy
en 12, que es la mitad del tamaño de la ventana gráfica (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 de elemento SVG, que crearás a continuación, y, por último, se le asigna un color de relleno que coincide con el color del texto de la página con currentColor
.
Los rayos del sol
A continuación, se agregan las líneas de los rayos de sol 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 stroke de cada línea. Las líneas y las formas circulares crean un sol agradable con rayos.
La Luna
Para crear la ilusión de una transición fluida entre la luz (el sol) y la oscuridad (la 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 y permiten que los colores blanco y negro quiten o incluyan partes de otro gráfico. El ícono de 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?

Puede ser útil probar tu SVG como si no se hubiera cargado el CSS 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, además del uso de currentColor
, proporcionan reglas de diseño mínimas para que el navegador las use si no se carga el CSS. Esto genera estilos defensivos agradables contra la turbulencia de la red.
Diseño
El componente de cambio de tema tiene una superficie pequeña, por lo que no necesitas una cuadrícula ni un diseño de Flexbox. En su lugar, se usan las transformaciones CSS y el posicionamiento de SVG.
Estilos
.theme-toggle
estilos
El elemento <button>
es el contenedor de las formas y los estilos de los íconos. Este contexto principal contendrá colores y tamaños adaptativos para pasar al SVG.
La primera tarea es convertir el botón en un círculo y quitar los estilos 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 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 ajustarse al tamaño del botón y, para que se vea más suave, redondea los extremos de las líneas:
.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 de ícono es un poco pequeño en 2rem
, lo que está bien para los usuarios de mouse, pero puede ser difícil para un puntero grueso como un dedo. Haz que el botón cumpla con muchos lineamientos de tamaño táctil usando una consulta de medios de desplazamiento para especificar un aumento de tamaño.
.theme-toggle {
--size: 2rem;
…
@media (hover: none) {
--size: 48px;
}
}
Estilos SVG del sol y la luna
El botón contiene los aspectos interactivos del componente de cambio de tema, mientras que el SVG interior contiene los aspectos visuales y animados. Aquí es donde el ícono puede embellecerse y cobrar vida.
Tema claro

Para que las animaciones de rotación y escala se produzcan desde el centro de las formas SVG, establece su transform-origin: center center
. 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 del 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 diseños de la Luna deben quitar los rayos solares, aumentar el 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 del botón principal posee los colores, que ya son adaptativos en un contexto oscuro y claro. La información de transición debe estar detrás de una consulta de medios de preferencia de movimiento del usuario.
Animación
En este punto, el botón debería ser funcional y tener estado, pero sin transiciones. En las siguientes secciones, se explica cómo definir las transiciones cómo y qué.
Cómo compartir consultas de medios y cómo 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 especificación CSS en borrador para variables de consultas 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 CSS únicas y fáciles de usar, importa la parte easings 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 las de la luna, y este efecto se logrará con aceleraciones elásticas. Los rayos del sol deben rebotar un poco a medida que giran, y el centro del sol debe rebotar un poco a medida que se ajusta su tamaño.
Los diseños predeterminados (tema claro) definen las transiciones, y los diseños del tema oscuro definen las personalizaciones para la transición a la luz:
.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 Animación de las Herramientas para desarrolladores de Chrome, puedes encontrar una línea de tiempo para las transiciones de animación. Se puede inspeccionar la duración de la animación total, los elementos y la sincronización de la aceleración.


La Luna
Las posiciones claras y oscuras de la luna 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 la 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 ni juguetona, 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 movimiento reducido
En la mayoría de los desafíos de GUI, intento mantener alguna animación, como las transiciones de opacidad, para los usuarios que prefieren un movimiento reducido. Sin embargo, este componente se sintió mejor con cambios de estado instantáneos.
JavaScript
Este componente requiere mucho trabajo de JavaScript, desde la administración de la información de ARIA para los 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 color oscuro indica que prefiere el esquema claro con este componente y, luego, vuelve a cargar la página, al principio la página se mostrará con el esquema oscuro y, luego, parpadeará para mostrar el esquema claro.
Para evitar esto, se ejecutaba una pequeña cantidad de JavaScript de bloqueo 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>
simple en el <head>
del documento, antes de cualquier CSS o marcado <body>
. Cuando el navegador encuentra una secuencia de comandos sin marcar como esta, ejecuta el código antes que el resto del HTML. Si se usa este momento de bloqueo con moderación, es posible establecer el atributo HTML antes de que el CSS principal pinte la página, lo que evita un destello o colores.
Primero, JavaScript verifica la preferencia del usuario en el almacenamiento local y, si no se encuentra nada, recurre a la preferencia del sistema:
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()
}
Luego, 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 se debe 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 etiqueta <html>
. La función intenta establecer ambos para mantenerlos sincronizados, pero en la primera ejecución solo podrá establecer la etiqueta HTML. Al principio, querySelector
no encontrará nada, y el operador de encadenamiento opcional garantizará 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 establecido su atributo data-theme
:
reflectPreference()
El botón aún necesita el atributo, por lo que debes esperar el evento de carga de la página. Luego, será seguro consultar, agregar objetos de escucha y establecer atributos en lo siguiente:
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 y desactivació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 nuevo estado. Una vez que se establece el nuevo estado, 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 una característica exclusiva de este interruptor de tema. Si un usuario cambia su preferencia del sistema mientras una página y este componente están visibles, el cambio de tema se modificará para que coincida con la nueva preferencia del usuario, como si el usuario hubiera interactuado con el cambio de tema al mismo tiempo que se produjo el cambio del sistema.
Para lograr esto, usa JavaScript y un objeto de escucha de eventos matchMedia
que detecte los cambios en una consulta de medios:
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 tú? 🙂
Diversifiquemos nuestros enfoques y aprendamos todas las formas de crear contenido en la Web. Crea una demostración, envíame por Twitter los vínculos y la agregaré a la sección de remixes de la comunidad que se encuentra a continuación.
Remixes de la comunidad
- @NathanG en Codepen con Vue
- @ShadowShahriar en Codepen
- @tomayac como un elemento personalizado
- @bramus con JavaScript estándar
- @JoshWComeau con react