Implementando un botón de cambio de tema (Dark Mode) con Astro y Tailwind
Te cuento cómo crear un selector de tema robusto que soporta modo claro, oscuro y preferencia del sistema, compatible con View Transitions.
- Astro
- Tailwind
- Javascript
Implementar un modo oscuro parece sencillo, pero hacerlo correctamente (evitando parpadeos al cargar y respetando la preferencia del sistema) requiere un poco más de cuidado, especialmente cuando usamos Astro y View Transitions.
En este rediseño de mi porfolio, he implementado un botón que cicla entre tres estados:
- 🌞 Claro
- 🌙 Oscuro
- 💻 Sistema (detecta la configuración de tu S.O.)
Aquí te explico cómo funciona la implementación técnica.
1. El Script Crítico (Evitando el FOUC)
El problema más común con el modo oscuro es el Flash of Unstyled Content (FOUC), donde la página carga en blanco un milisegundo antes de volverse oscura. Para evitar esto, necesitamos un script bloqueante en el <head> de nuestro Layout.astro.
Este script debe ser is:inline para ejecutarse inmediatamente antes de que el navegador renderice el cuerpo de la página:
<!-- src/layouts/Layout.astro -->
<script is:inline>
const getThemePreference = () => {
if (typeof localStorage !== "undefined" && localStorage.getItem("theme")) {
return localStorage.getItem("theme");
}
return "dark"; // Tema por defecto
};
const isDark = () => {
const theme = getThemePreference();
if (theme === "system") {
return window.matchMedia("(prefers-color-scheme: dark)").matches;
}
return theme === "dark";
};
const setTheme = () => {
const dark = isDark();
document.documentElement.classList.toggle("dark", dark);
};
setTheme();
// Fundamental para View Transitions: re-ejecutar al cambiar de página
document.addEventListener("astro:after-swap", setTheme);
</script>
2. El Componente ThemeToggle
El componente del botón (ThemeToggle.astro) maneja la interacción del usuario. No solo cambia la clase CSS, sino que guarda la preferencia en localStorage.
Una parte interesante es cómo manejamos el ciclo de los iconos. Al hacer clic, calculamos el siguiente estado en el array themes:
// Lógica simplificada dentro del componente
const themes = ["light", "dark", "system"];
const handleToggleClick = () => {
const currentTheme = getThemePreference();
const currentIndex = themes.indexOf(currentTheme);
// Ciclo infinito: 0 -> 1 -> 2 -> 0
const nextIndex = (currentIndex + 1) % themes.length;
const nextTheme = themes[nextIndex];
localStorage.setItem("theme", nextTheme);
reflectTheme(); // Función que actualiza el DOM e iconos
};
3. Sincronización con View Transitions
Al usar Astro View Transitions, el estado del DOM se preserva entre navegaciones, pero a veces necesitamos reinicializar los event listeners. Por eso, en el script del componente, usamos el evento astro:page-load:
document.addEventListener("astro:page-load", () => {
const button = document.getElementById("theme-toggle");
if (button) {
button.addEventListener("click", handleToggleClick);
}
reflectTheme(); // Asegurar que el icono correcto se muestra al navegar
});
Conclusión
Con esta configuración he logrado una experiencia de usuario fluida:
- El tema se aplica instantáneamente (sin parpadeos).
- Se respeta la preferencia del sistema operativo si el usuario lo desea.
- Funciona perfectamente con la navegación tipo SPA de Astro.
- Y lo mejor de todo es que primera elección es el modo oscuro “Dark mode for ever”.