Modo Dark con Tailwind CSS y Alpine.js

Una tendencia que existe últimamente, entre los web apps, es la introducción de un theme dark. Y ahora que estoy desarrollando Olidisco quería intentar implementarlo también.

Tailwind es mi framework CSS de trabajo y cuenta con una variante dark para dar estilos cuando el modo dark está activo.

Por ejemplo, para cambiar el color del fondo de blanco a un gris muy oscuro y el texto de gris oscuro a blanco podemos utilizar el siguiente fragmento.

<div class="bg-white dark:bg-gray-900">
  <p class="text-gray-900 dark:text-white">
    Almacenamiento de imágenes en OliDisco.
  </p>
</div>

Para poder ver el cambio de estilos a dark, nuestro sistema debe tener activo el modo dark.

Activar manualmente la variante dark

Probablemente, queramos activar manualmente la versión dark independientemente de la configuración establecida en nuestro sistema.

Por defecto, Tailwind emplea la estrategia media para el modo dark. Que es usar la configuración del sistema. Pero podemos cambiarlo al modo class para que el modo dark se active cuando la clase dark este presente en nuestro HTML.

En nuestro archivo tailwind.config.js tendríamos lo siguiente.

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

Y en nuestro HTML aplicamos la clase dark.

<html class="dark">
<body>
  <div class="bg-white dark:bg-gray-900">
    <!-- ... -->
  </div>
</body>
</html>

Switch para activar modo dark

Para activar el modo dark de forma dinámica podemos utilizar un poco de Javascript para crear un botón switch que lo active y lo desactive.

En mis proyectos uso el framework de Javascript Alpine. Por lo que el siguiente fragmento es para registrar globalmente darkMode con los métodos para activar o desactivar el modo dark.

<button
    type="button"
    x-data=""
    @click="$store.darkMode.inactive()"
>
    Light
</button>

<button
    type="button"
    x-data=""
    @click="$store.darkMode.active()"
>
    Dark
</button>

<script>
    document.addEventListener('alpine:init', () => {
        Alpine.store('darkMode', {
            active() {
                localStorage.theme = 'dark'

                document.documentElement.classList.add('dark')
            },

            inactive() {
                localStorage.theme = 'light'

                document.documentElement.classList.remove('dark')
            },
        })
    })
</script>

Haciendo clic en el botón dark, se establece la variable theme con el valor dark en el almacenamiento local y añade la clase dark a la etiqueta html.

Haciendo clic en el botón light, se establece la variable theme con el valor light en el almacenamiento local y quita la clase dark a la etiqueta html.

Hacer persistente el modo dark

Aunque hayamos activado la versión dark, la preferencia no es persistente si refrescamos la página.

Para hacerlo persistente podemos emplear el siguiente fragmento de Javascript para leer la variable theme y, si el valor es dark establecer la clase dark en el HTML. De lo contrario, quitarlo.

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

Respetar la preferencia del sistema

Aunque ahora podemos activar o desactivar el modo dark, qué tal si queremos que se respete la preferencia del sistema. Es decir, que cuando el sistema tenga el modo dark activo, también activar la versión dark de la app.

Remplazamos el fragmento anterior con el siguiente, para utilizar el almacenamiento local o si no existe utilizar la preferencia del sistema.

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

Ahora falta cambiar nuestro fragmento Alpine para añadir también un botón que restablezca la variable theme del almacenamiento local y leer la preferencia del sistema.

<button
    type="button"
    x-data=""
    @click="$store.darkMode.inactive()"
>
    Light
</button>

<button
    type="button"
    x-data=""
    @click="$store.darkMode.active()"
>
    Dark
</button>

<button
    type="button"
    x-data=""
    @click="$store.darkMode.reset()"
>
    Sistema
</button>

<script>
    document.addEventListener('alpine:init', () => {
        Alpine.store('darkMode', {
            active() {
                localStorage.theme = 'dark'

                document.documentElement.classList.add('dark')
            },

            inactive() {
                localStorage.theme = 'light'

                document.documentElement.classList.remove('dark')
            },

            reset() {
                localStorage.removeItem('theme')

                if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
                    document.documentElement.classList.add('dark')
                } else {
                    document.documentElement.classList.remove('dark')
                }
            }
        })
    })
</script>

Detectar el cambio de sistema a modo Dark

En Mac OS, si tenemos la preferencia del modo dark en automático, la versión dark se activará cuando haya anochecido y se desactivará al amanecer.

Para poder cambiarlo dinámicamente, utilizamos el siguiente fragmento para Alpine que añade el método init y poder escuchar cuándo el sistema haya activado o desactivado el modo dark.

<script>
    document.addEventListener('alpine:init', () => {
        Alpine.store('darkMode', {
            init() {
                window.matchMedia('(prefers-color-scheme: dark)').onchange = function() {
                    if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
                        document.documentElement.classList.add('dark')
                    } else {
                        document.documentElement.classList.remove('dark')
                    }
                }
            },

            active() {
                localStorage.theme = 'dark'

                document.documentElement.classList.add('dark')
            },

            inactive() {
                localStorage.theme = 'light'

                document.documentElement.classList.remove('dark')
            },

            reset() {
                localStorage.removeItem('theme')

                if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
                    document.documentElement.classList.add('dark')
                } else {
                    document.documentElement.classList.remove('dark')
                }
            }
        })
    })
</script>

Modo dark completado

Con esto, finalmente hemos implementado el modo dark en una app tomando en cuenta todos los casos puntuales:

  • Activar manualmente el modo dark
  • Switch para activar o desactivar el modo dark
  • Restablecer el modo dark a las preferencias del sistema
  • Detectar automáticamente cuándo el sistema haya cambiado el modo dark

El código completo se encuentra en el siguiente repositorio de Github.