Tema oscuro en Next.js sin parpadeo del tema equivocado
Respuesta rápida
Un tema oscuro en Next.js necesita tres cosas, y las variables CSS son solo la primera:
- Definir la paleta por tema — un bloque
[data-theme='dark'], otro[data-theme='light'], con los tokens redefinidos en cada uno. - Resolver el tema antes del primer pintado — un
<script>síncrono pequeño en_documentque leelocalStorageyprefers-color-schemey ponedata-themeen<html>. UnuseEffectllega tarde: la paleta equivocada ya está en pantalla. - Poner
color-scheme— si no, los scrollbars, los<select>y el lienzo por defecto se quedan claros sobre una página oscura.
La parte que casi todos los tutoriales hacen mal (el mío incluido, durante tres años) es la segunda. Registrar un listener de change en matchMedia no es lo mismo que leer la preferencia: el listener solo dispara si el visitante cambia el tema del sistema con la pestaña ya abierta.
Lo que publiqué, y por qué nunca funcionó
La versión original de este post describía exactamente lo que corría esta web: variables CSS, data-theme="light" a fuego en _document, y un hook suscrito a prefers-color-scheme. Tenía dos defectos que se tapaban el uno al otro lo bastante bien como para que no lo viera en tres años.
El primero está en el hook. Llamaba a mediaQuery.addEventListener('change', handler) y nada más — nunca leía mediaQuery.matches. change dispara cuando la preferencia cambia, no cuando te suscribes, así que quien tenía el modo oscuro activado antes de abrir la página se llevaba la paleta clara y se quedaba con ella. La única manera de ver el tema oscuro era cambiar el ajuste del sistema con la pestaña ya abierta, que es exactamente lo que yo hacía cada vez que lo "probaba".
El segundo es peor. El hook lo montaba una sola página — el diálogo de ajustes —, así que en el resto de rutas data-theme nunca se movía del "light" escrito en _document. La paleta oscura no es que fuera difícil de alcanzar: es que no se renderizaba nunca. Cuando por fin la apliqué a todo el sitio, una superficie de ventana que llevaba ahí desde 2023 resultó estar pintando blanco sobre blanco, porque usaba un token --white-color independiente del tema que nadie había visto jamás en oscuro.
Mientras tanto, en globals.css había esto:
Esa regla lee el sistema operativo directamente, mientras que la paleta de la página venía de data-theme. Sobre Next.js 15.5, en agosto de 2026 y con el sistema en oscuro, la página reportaba data-theme="light" y color-scheme: dark a la vez: fondo claro, tokens de texto claros y scrollbars oscuros, porque al navegador se le había dicho que la página era oscura y el CSS decía lo contrario.
1 — Variables CSS por tema
Esta parte del post original estaba bien y no ha cambiado. Los colores viven como tripletas de canales para poder reutilizarlos con alfa, y cada tema los redefine:
La trampa está en el bloque :root. Lo que se quede ahí es independiente del tema por definición, y un token como --white-color usado como color de superficie seguirá siendo blanco bajo [data-theme='dark']. Si un valor nombra un color en vez de un rol, no pinta nada en un componente con tema.
2 — color-scheme, la propiedad que arregla la interfaz del propio navegador
color-scheme es lo que le dice al navegador qué paleta usar para las partes de la página que pinta él: scrollbars, checkboxes, radios, desplegables <select>, el subrayado del corrector ortográfico y el color del lienzo por defecto antes de que cargue tu CSS. Es Baseline —disponible en todos los motores— desde enero de 2022, y falta en la mayoría de tutoriales de tema oscuro, incluida la primera versión de este.
Dos detalles importan más que la propiedad en sí:
Decláralo dentro de los bloques [data-theme], no en una media query. Una media query sigue al sistema operativo. Tu paleta sigue al tema aplicado, que después de un toggle no es lo mismo. Ligados al mismo selector, no pueden separarse.
También arregla el color del flash previo al pintado. Con color-scheme: dark, el navegador pinta su lienzo por defecto en oscuro en vez de en blanco, así que hasta el fotograma anterior a tu hoja de estilos tiene el color correcto.
3 — Resolver el tema antes del primer pintado
El servidor no puede conocer la preferencia del visitante —no existe cabecera de petición para prefers-color-scheme—, así que el HTML tiene que salir con un valor por defecto y corregirse en el navegador. La corrección tiene que ocurrir antes de que se pinte el body, lo que descarta useEffect.
Un script síncrono en el head lo consigue. Bloquea el parser, que normalmente es algo a evitar, pero aquí son cuatro bytes y es justo el objetivo:
Apuntes sobre los detalles, porque cada uno está por un motivo:
data-theme="light"se queda en el marcado. Es el fallback para quien tenga JavaScript desactivado, y el script lo sobrescribe antes de pintar nada.- El valor guardado se comprueba primero, para que la elección explícita gane al sistema. Un toggle que el siguiente cambio del sistema deshace en silencio no es un toggle.
try/catchalrededor delocalStorage. En modo privado y con ciertas extensiones, leerlo lanza. Sin la protección, el script entero muere y todo el mundo se queda en claro.- Va en
_document, no en_app._documentse renderiza solo en servidor y nunca se hidrata, así que mutardocumentElementdesde ahí no provoca ningún error de hidratación.
Meterlo en el head es además lo que lo hace independiente de tu árbol de componentes. Ese fue el fallo real en mi caso: el tema lo aplicaba un hook que solo montaba una página. En el head se ejecuta en todas las rutas, sin importar qué se renderice.
4 — El hook: leer el valor inicial y guardar la elección
El hook sigue haciendo falta —para el toggle, y para seguir al sistema mientras la pestaña está abierta—, pero tiene que leer matches al montar en vez de esperar a un evento change:
El handler de change no hace nada a propósito una vez hay una elección guardada. Sin esa comprobación, a quien eligió claro se le daría la vuelta la próxima vez que su portátil pasara a modo noche — el toggle parecería roto en lugar de sobrescrito.
toggleTheme usa la forma funcional de setTheme en vez de leer theme del closure, así no necesita theme en su lista de dependencias y no puede actuar sobre un valor obsoleto.
5 — Conectar el toggle
A estas alturas no queda nada especial. El componente lee el tema actual para su etiqueta y llama a toggleTheme:
theme es null en el primer render — el valor solo se resuelve dentro del efecto, porque leer localStorage o matchMedia durante el render haría que el marcado del servidor y el del cliente no coincidieran y saldría un error de hidratación. Si necesitas que el texto sea correcto también en el HTML del servidor, etiqueta el control desde el data-theme del documento.
light-dark(), y si compensa
light-dark() mete los dos valores en una sola declaración y elige entre ellos según el esquema de color en uso del elemento:
Es Baseline desde mayo de 2024, así que hoy es usable con fallback, y quita una molestia real: la estructura de dos bloques donde cada token hay que declararlo dos veces en dos sitios alejados, y añadir uno implica acordarse de añadirlo en ambos.
No he migrado esta web a ello, y el motivo no es el soporte de navegadores. light-dark() se resuelve contra color-scheme, así que solo cubre el par claro/oscuro — en cuanto quieras un tercer tema, o una sobreescritura por sección, vuelves a redefinir variables bajo un selector. Con un set de tokens ya partido en dos bloques, la reescritura compra CSS más limpio y nada que el visitante pueda ver. En un proyecto nuevo empezaría por ahí.
La pega que nadie menciona: theme-color
<meta name="theme-color"> colorea el cromo del navegador en móvil, y acepta un atributo media:
Ese atributo media lee el sistema operativo, y un elemento <meta> no puede leer localStorage. Así que quien sobrescriba el tema tendrá la página en una paleta y el cromo del navegador en la otra. Cambiar el atributo content desde el handler del toggle es la única forma de mantenerlos sincronizados, y conviene decidir si te importa antes de escribir ese código.
Cómo comprobar que funciona de verdad
Tres comprobaciones, en la consola del navegador, con el sistema en oscuro:
La segunda es la que destapó el fallo de esta web: data-theme decía light mientras colorScheme decía dark. Si esas dos alguna vez no coinciden, la paleta y la interfaz del navegador están leyendo fuentes distintas.
Todo el código en mi repositorio de github, si te gusta este proyecto, o si el contenido te ha ayudado de alguna manera puedes recompensarme con una ⭐️, ¡Gracias!
