Saltar al contenido principal

Cómo crear un selector de tema claro y oscuro con Javascript

Esto de claro y oscuro depende mucho de los gustos, hay gente que prefiere tenerlo todo en oscuro para que la luz de la pantalla no sea tan brillante y otros que prefieren todo en claro para facilitar la lectura. También depende de la estancia en la que estés, si por ejemplo estás en exteriores con portátiles, con un tema oscuro no vas a ver casi nada. Es por esto que se suele poner un selector en las webs para que los usuarios escojan entre uno o otro.

Este selector se puede implementar con muy pocas líneas de Javascript 🚧, y además podemos guardarlo en memoria del navegador para que recuerde la decisión del usuario entre cambios de páginas.

El selector que vamos a crear en este artículo está basado en el template para crear blogs que saqué en el artículo de Cómo crear un blog con ficheros markdown.

En este artículo vamos a ver un selector sencillo entre tema claro y oscuro, pero puedes crear este mismo sistema para tener múltiples temas a elegir en tu página.

Creando la parte visual

Vamos al lío. La UI de este componente va a ser muy sencilla. Un simple botón con un icono de un sol y una luna dependiendo del tema en el que esté el usuario.

Imagen con el selector que vamos a crear, en la imagen se ve únicamente un icono de una luna

Aquí un truco que uso es meter los SVGs de los iconos dentro de CSS, así no hay que meter los dos en el HTML ocultando uno de ellos. Recuerda meter los iconos como SVG para que se vean lo mejor posible ocupando muy poco, intenta evitar siempre los .png y los .jpg.

Aquí el HTML, más simple no puede ser.

<button
  class="dark-toggler"
  aria-label="Toggle color mode"
  title="Toggle color mode"
  data-theme-toggle
>
  <div class="toggler-icon"></div>
</button>

Ojo al detalle de meterlo como <button> y no como <div>, recuerda poner siempre los elementos interactuables en botones para facilitar la accesibilidad. Dentro he metido un div vacío para meter ahí el icono vía CSS.

Vamos con el CSS, muy simple también:

  .dark-toggler {
    background: unset;
    border: none;
    cursor: pointer;
  }
  .dark-toggler:hover .toggler-icon {
    background-color: var(--color-primary);
  }
  .toggler-icon {
    width: 18px;
    height: 18px;
    background-color: var(--color-text);
    transition: background-color 0.2s ease-in-out;
    -webkit-mask-size: 18px;
    -webkit-mask-position: 50% 50%;
    -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='currentColor' %3E%3Cpath fill-rule='evenodd' d='M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z' clip-rule='evenodd' /%3E%3C/svg%3E");
  }
  html[data-theme="dark"] .toggler-icon {
    -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='currentColor' viewBox='0 0 24 24' %3E%3Cpath d='M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z' /%3E%3C/svg%3E");
  }

Lo primero que salta a la vista son los dos iconos metidos como SVG dentro del elemento -webkit-mask-image, como he dicho antes esto es un truquito para no tener que meter dos SVGs en el HTML. Para algo así de simple y concreto no está mal esto, pero para un sistema de iconos o otro tipo de SVGs sí que recomiendo mejor meterlos en el HTML.

Con el -webkit-mask-position posiciono el icono en el centro del botón.

Otro elemento que resalta son las Variables CSS 🚧, la de var(--color-primary)y la de var(--color-text). Esto es para facilitar el tema del cambio de tema, ya que puedes definir estas variables de forma global y al ser usadas en todos los sitios de la web, al sobrescribir su valor, se aplicará en toda la web.

De forma global tengo un CSS que meto en todas las páginas de la web. Dentro, aparte de estilos globales, tengo la declaración de las variables CSS, es decir:

  html {
    --color-background: #fffcf0;
    --color-background-shade: #fbf3d9;
    --color-text: #22211d;
    --color-text-shade: #575650;
    --color-primary: #147d82;
  }

En realidad tengo muchas más, pero he puesto solo las varaibles de los colores, que son las que nos interesan para este artículo.

Debajo tengo las variables para cuando el tema es oscuro, simplemente redefino su valor, y automáticamente se cambiarán en toda la web, en este caso cuando la etiqueta html tenga el data attribute de data-theme con valor dark. Este atributo es el que cambiaremos mediante Javascript al pulsar el botón.

  html[data-theme="dark"] {
    --color-background: #100f0f;
    --color-background-shade: #202424;
    --color-text: #d2dee1;
    --color-text-shade: #989f9f;
    --color-primary: #62aba4;
  }

Luego tienes que usar estas variables para el color, tanto del background como del texto, de forma global:

html {
  background-color: var(--color-background);
  color: var(--color-text);
}

Creando la interactividad con Javascript

Vamos ahora a hacer que el botón funcione al pulsarlo. Lo primero que voy a hacer va a ser crear una función para cambiar el data attribute que he mencionado antes para que se aplique un tema o otro.

/**
 * Sets the theme globally
 * @param {String} theme - dark or light
 *
**/
function setTheme(theme) {
  const html = document.querySelector("html");
  html.setAttribute("data-theme", theme);
}

Como no estoy usando Typescript 🚧 he metido una descripción de lo que hace la función (aunque su nombre debería ser explicativo) y he documentado los parámetros ya que a priori no se sabe lo que hay que pasar en el parámetro theme, y no me gusta poner el tipo de variable como nombre, por ejemplo themeString.

Vamos ahora a añadir un listener en el botón para saber cuándo el usuario lo pulsa. Y dentro simplemente vamos a llamar de momento a la función para setear el tema oscuro y ver que funciona.

addButtonThemeListener();

/**
 * Listens for the click of the button and execute the theme change
**/
function addButtonThemeListener() {
  const buttonToggler = document.querySelector("[data-theme-toggle]");
  buttonToggler.addEventListener("click", () => {
		setTheme("dark");
  });
}

Con esto ya puedes ver que la página cambia a tema oscuro al pulsar el botón, y que además el botón cambia de icono, aunque no puedes volver al tema claro, pero ya es un avance.

Para volver al tema claro necesitamos de alguna forma saber en qué tema estamos. Para ello voy a crear una variable global que lleve el estado y una función que me diga el nuevo estado, por último lo guardo en la variable, es decir:

let currentTheme = "light";

addButtonThemeListener();

/**
 * Listens for the click of the button and execute the theme change
**/
function addButtonThemeListener() {
  const buttonToggler = document.querySelector("[data-theme-toggle]");
  buttonToggler.addEventListener("click", () => {
	  const newTheme = getNewTheme(currentTheme);
		setTheme(newTheme);
		currentTheme = newTheme;
  });
}

/**
 * Returns the new theme
 * @param {String} theme - the current app theme, dark or light
 *
**/
function getNewTheme(theme) {
	return theme === "dark" ? "light" : "dark";
}

Listo, con esto el usuario ya puede seleccionar el tema oscuro y luego volver al claro, pero hay un problema, al recargar la página el tema escogido no se mantiene, vamos a ello. De momento voy a crear un par de funciones para guardar en la memoria del navegador (localStorage) y recuperar el valor guardado, muy sencillo:

/**
 * Returns the theme saved in memory
 * @return {String} theme - the saved theme
 *
**/
function getSavedTheme() {
  return localStorage.getItem("theme");
}

/**
 * Saves theme in memory
 * @return {String} theme - the theme to save
 *
**/
function saveTheme(theme) {
  localStorage.setItem("theme", theme);
}

Ahora solo tenemos que recuperar el tema guardado al inicializar la variable global y actualizarlo en memoria al cambiar de tema. Tras pillar el tema de memoria hay que hacer un setTheme para actualizarlo en el HTML y que se aplique.

let currentTheme = getSavedTheme();
setTheme(currentTheme);

addButtonThemeListener();

/**
 * Listens for the click of the button and execute the theme change
**/
function addButtonThemeListener() {
  const buttonToggler = document.querySelector("[data-theme-toggle]");
  buttonToggler.addEventListener("click", () => {
	  const newTheme = getNewTheme(currentTheme);
		setTheme(newTheme);
		currentTheme = newTheme;
		saveTheme(newTheme);
  });
}

¡Y con esto ya estaría! Ahora al navegar entre páginas o recargar se mantiene la decisión del usuario. Lo único es que como se guarda en la memoria del navegador, si abre la página en el móvil tendrá que escoger también ahí el tema que quiere.

Para mantener el tema entre dispositivos y navegadores no te queda más remedio que guardar el tema y las preferencias del usuario en servidor, es decir, en la base de datos, asociado a la cuenta del usuario y para ello el usuario tiene que iniciar sesión.

Respetando la decisión inicial del usuario

Hay un detalle más que podemos añadir, el broche de oro, hacer que si el usuario no ha escogido todavía el tema, usar por defecto el que tenga escogido de tema del sistema operativo. Esto no sé si funciona en todos los sistemas porque no lo he probado, pero en teoría si por ejemplo el usuario de la web tiene el Mac en tema oscuro, al entrar por defecto la web saldría en oscuro.

Para hacer esto hay que pillar la mediaquery de preferencia por el tema oscuro, vamos a crear una función para ello. Si la mediaquery matchea quiere decir que el usuario prefiere el oscuro.

/**
 * Get the default theme for the user
 * @return {String} theme - the theme of the user
 *
**/
function getDefaultTheme() {
  const systemSettingDark = window.matchMedia("(prefers-color-scheme: dark)");
  const systemSettingTheme = systemSettingDark.matches ? "dark" : "light";
  const savedTheme = getSavedTheme();
  return localStorageTheme ? localStorageTheme : systemSettingTheme;
}

También en la función lo que hago es comprobar si hay un valor almacenado en memoria, y en ese caso usar ese, haciendo que la decisión del usuario tenga preferencia sobre el tema que tenga puesto en el sistema operativo.

Ahora simplemente la llamamos en la variable global. El resto de código se mantiene igual. De paso aprovecho y te paso todo el código Javascript para que lo puedas copiar fácil.

let currentTheme = getDefaultTheme();
setTheme(currentTheme);

addButtonThemeListener();

/**
 * Listens for the click of the button and execute the theme change
**/
function addButtonThemeListener() {
  const buttonToggler = document.querySelector("[data-theme-toggle]");
  buttonToggler.addEventListener("click", () => {
	  const newTheme = getNewTheme(currentTheme);
		setTheme(newTheme);
		currentTheme = newTheme;
    saveTheme(newTheme);
  });
}

/**
 * Get the default theme for the user
 * @return {String} theme - the theme of the user
 *
**/
function getDefaultTheme() {
  const systemSettingDark = window.matchMedia("(prefers-color-scheme: dark)");
  const systemSettingTheme = systemSettingDark.matches ? "dark" : "light";
  const savedTheme = getSavedTheme();
  return savedTheme ? savedTheme : systemSettingTheme;
}

/**
 * Returns the new theme
 * @param {String} theme - the current app theme, dark or light
 *
**/
function getNewTheme(theme) {
	return theme === "dark" ? "light" : "dark";
}

/**
 * Sets the theme globally
 * @param {String} theme - dark or light
 *
**/
function setTheme(theme) {
  const html = document.querySelector("html");
  html.setAttribute("data-theme", theme);
}

/**
 * Returns the theme saved in memory
 * @return {String} theme - the saved theme
 *
**/
function getSavedTheme() {
  return localStorage.getItem("theme");
}

/**
 * Saves theme in memory
 * @return {String} theme - the theme to save
 *
**/
function saveTheme(theme) {
  localStorage.setItem("theme", theme);
}

Y poco más. Obviamente el código se puede mejorar mucho, incluso se puede añadir más funcionalidad como una leve transición CSS entre los temas. En este artículo te quería explicar la base, ahora tú puedes tunear esto a tu gusto.

Por cierto, te paso también este link a Codepen con todo el código de este artículo.