Basándome en Laravel Jetstream con Livewire, que también incluye Alpine.js y TailwindCSS, en esta entrada muestro como implementar el modo oscuro en nuestras aplicaciones.

Modo oscuro

El modo oscuro

Cada vez es más frecuente que las aplicaciones se desarrollen pensando en implementar un modo oscuro (dark mode) que permita descasar la vista del usuario con colores más apagados. La posibilidad de implementar estos modos con los frameworks de CSS como Tailwindcss han ayudado a que esto sea así.

Básicamente, el modo oscuro de Tailwindcss consiste en implementar clases con el prefijo dark: delante. Ej.:

<div class="bg-white dark:bg-black">

Esto cambiará el fondo de nuestra página si el modo oscuro está activo.

Para activarlo, es suficiente con indicarlo añadiendo la clase dark en la etiqueta <html>:

<html class="dark">

Mediante JavaScript podemos añadir o quitar esta clase y conseguir así que nuestra aplicación cambie el fondo del ejemplo anterior:

document.documentElement.classList.add('dark');
document.documentElement.classList.remove('dark')

El document.documentElement hace referencia al elemento raíz del documento, en este caso, la etiqueta <html>.

Pasos

Para poder mostrar el modo correctamente y cambiar de modo de forma interactiva, debemos configurar Tailwind en Laravel, necesitaremos un interruptor (switch) en nuestra aplicación, y querremos también guardar nuestra opción del tema elegido para que se muestre correctamente la próxima vez que accedamos a la aplicación. para esto último me voy a servir de local.Storage. Esto implica lo siguiente:

  1. Indicar a Tailwind que debe usar el cambio de modo oscuro manual.
  2. Comprobar el modo correspondiente al inicio en local.Storage.
  3. Guardar el tema la primera vez comprobando el modo del sistema al inicio.
  4. Mostrar los botones para cambiar de modo.
  5. Cambiar entre modo claro y modo oscuro y viceversa.

Indicar a Tailwind que debe usar el cambio de modo oscuro manual

Por defecto, Tailwindcss en Laravel está preparado para implementar el tema de las preferencias de modo en el sistema operativo. Si quiero cambiar de modo manualmente (switch) en lugar de depender de la preferencia del sistema, debo indicarlo en el fichero tailwind.config.js mediante darkMode: 'class':

module.exports = {
  darkMode: 'class',
  // ...
}

Ahora, en vez de escuchar las preferencias de prefers-color-scheme del CSS, Tailwind usará el modo oscuro sólo si este está indicado en `<html class="dark"> sin tomar en cuenta las preferencias del sistema.

Comprobar el modo correspondiente al inicio en local.Storage

Cuando elegimos uno de los dos modos, ya sea el modo claro o el modo oscuro para nuestra aplicación, podemos guardarlo en local.Storage para así recuperarlo la siguiente vez que abramos nuestra aplicación. Podemos guardar true o 1 para indicar que el modo oscuro está activo o bien false o 0 para indicar lo contrario. En mi caso usaré 1 y 0.

En el siguiente código en javascript compruebo el valor de localStorage.dark, y en función de su contenido, añado o quito la clase darka la etiqueta <html>.

if (localStorage.dark == 1) {
    document.documentElement.classList.add('dark');
} else {
    document.documentElement.classList.remove('dark');
}

Tener este dato guardado nos permite, además de cambiar de modo, mostrar el icono correcto para intercambiar de modo. Pongamos que usamos un icono de media Luna para indicar el cambio al modo oscuro y un icono de Sol para indicar el cambio al modo claro. Si iniciamos la aplicación en modo oscuro, el icono a mostrar debe ser el Sol, y al contrario si iniciamos en el modo claro.

El lugar adecuado para este código es el fichero en la ruta /resources/js/app.js que es llamado desde el fichero en la ruta /resources/views/layouts/app.blade.phppero dejándolo en app.jsse produce un molesto efecto de pestañeo (flicker) que nos muestra primero la página en modo claro, y seguidamente hace el cambio al modo oscuro. Así pues, lo incluyo en el fichero app.blade.phpen el head debajo del title pero antes de los links, de la siguiente forma:

<script>
    if (localStorage.dark == 1) {
        document.documentElement.classList.add('dark');
    } else {
        document.documentElement.classList.remove('dark');
    }
</script>

Guardar el tema la primera vez comprobando el modo del sistema al inicio

La primera vez que abrimos nuestra aplicación no tenemos guardado ningún valor en local.Storage, así que conviene guardar el valor en función de algún criterio. Por defecto podría indicar que le modo oscuro está desactivado, pero ya que tengo la opción de conocer cuál es el tema del sistema operativo, ¿por qué no aprovecharlo? el siguiente código completa esta opción:

<script>
    if (localStorage.dark == 1 || (!('dark' in localStorage)
    && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
        localStorage.dark = 1;
        document.documentElement.classList.add('dark');
    } else {
        localStorage.dark = 0;
        document.documentElement.classList.remove('dark');
    }
</script>

Si existe el valor dark en local.Storage, o si no existe pero las preferencias del sistema prefers-color-scheme indican que tenemos activado el modo oscuro, entonces guardo en local.Storage el valor de dark = 1 y añado la clase dark a la etiqueta <html>. En caso contrario, guardo el valor dark = 0 en local.Storage y quito la clase dark de la etiqueta <html> ya que no tendremos activo el modo oscuro previamente, ni en local.Storage ni en el sistema operativo.

Mostrar los botones para cambiar de modo

En esta parte vamos a ver cómo usar Alpine.js para mostrar u ocultar un botón en vez de otro. el siguiente código debemos incluirlo en el fichero en la ruta /resources/views/navigation-menu-blade.php que contiene el menú en la parte superior del tema de la aplicación que trae por defecto Laravel Jetstream. Debemos incluirlo además dos veces, una para el modo responsive y otra para el modo normal.

<!-- Selector de tema oscuro -->
<div class="mr-2">
    <svg id="moon" class="setMode h-4 w-4 text-gray-400 hover:text-gray-500 cursor-pointer" fill="none" @click="toggle" :class="{'block': !show, 'hidden': show}" x-cloak xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke="currentColor">
        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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" />
    </svg>
    <svg id="sun" class="setMode h-5 w-5 text-yellow-200 hover:text-yellow-300 cursor-pointer" fill="none" @click="toggle" :class="{'hidden': !show, 'block': show}" x-cloak xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke="currentColor">
        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
    </svg>
</div>

el <div> contiene dos SVG que corresponden a los iconos de la Luna y el Sol, pero incluyen algunas etiquetas de Alpine.js.

Con :class="{'block': !show, 'hidden': show} se mostrará u ocultará el SVG en función del valor de la variable show. Para definir su valor inicial, en la etiqueta <nav> de la línea 1, incluyo:

x-data="{ open: false, show: localStorage.dark == 1 ? true: false, toggle() { this.show = !this.show } }"

Con x-data inicializo variables. Lo hago pasando un objeto JavaScript con algunos valores. En este caso, el open: false del principio ya estaba ahí, pues laravel lo usa para desplegar o no el menú de usuario. Veamos el resto.

Con show: localStorage.dark == 1 ? true: false indico el valor de show en función de lo que indica local.Storage. Si local.Storage es 1 entonces show será true, y se mostrará el icono del Sol, según indica {'hidden': !show, 'block': show} del segundo SVG, ('hidden' false, 'block' true).

Para saber más sobre la directiva :class, consultar: x-bind en Tailwind.js.

Con la directiva @click="toggle" llamo a la función toggle() { this.show = !this.show } definida en el x-data cuando se hace clic sobre el icono. Esto sólo cambiará el icono haciendo que la variable show alterne su valor entre true y false.

Cambiar entre modo claro y modo oscuro y viceversa

Hemos visto cómo cambiar entre el icono para el modo claro y oscuro y viceversa, pero lo que queremos es que se cambie también el modo en toda la aplicación. Para ello debemos gestionar eventos asociados a los iconos de tal forma que al hacer clic en ellos no sólo cambien el icono sino que añadan o quiten la etiqueta dark del <html> y de esta forma hacer el cambio de modo efectivo. para conseguirlo, incluyo el siguiente código en el fichero en la ruta /resources/js/app.js:

document.querySelectorAll(".setMode").forEach(item =>
item.addEventListener("click", () => {
        if (localStorage.dark == 1) {
            localStorage.dark = 0;
            document.documentElement.classList.remove('dark');
        } else {
            localStorage.dark = 1;
            document.documentElement.classList.add('dark');
        }
    })
)

Con querySelectorAll(".setMode") busco todas las clases setMode en la página. De esta forma localizo a todos los SVG que están implicados con los iconos de cambio de modo (ver que cada SVG incluye class="setMode ...), y a cada uno de ellos le añado un evento click de tal forma que, al hacer clic sobre estos iconos, realice la siguiente comprobación: Si el valor de localStorage.dark == 1 guardar el valor 0 en local.Storage y quitar el modo oscuro con document.documentElement.classList.remove('dark'). en caso contrario, guardar el valor 1 en local.Storage y poner el modo oscuro con document.documentElement.classList.add('dark').

Guarda todo y no olvides ejecutar npm run dev desde el terminal para que los cambios funciones correctamente.

Enlaces