Tema escuro en Next.js sen parpadeo do tema equivocado
Resposta rápida
Un tema escuro en Next.js precisa tres cousas, e as variables CSS son só a primeira:
- Definir a paleta por tema — un bloque
[data-theme='dark'], outro[data-theme='light'], cos tokens redefinidos en cada un. - Resolver o tema antes do primeiro pintado — un
<script>síncrono pequeno en_documentque lelocalStorageeprefers-color-schemee pondata-themeen<html>. UnuseEffectchega tarde: a paleta equivocada xa está na pantalla. - Poñer
color-scheme— se non, as barras de desprazamento, os<select>e o lenzo por defecto quedan claros sobre unha páxina escura.
A parte que case todos os titoriais fan mal (o meu incluído, durante tres anos) é a segunda. Rexistrar un listener de change en matchMedia non é o mesmo que ler a preferencia: o listener só dispara se o visitante cambia o tema do sistema coa lapela xa aberta.
O que publiquei, e por que nunca funcionou
A versión orixinal deste post describía exactamente o que corría esta web: variables CSS, data-theme="light" a lume en _document, e un hook subscrito a prefers-color-scheme. Tiña dous defectos que se tapaban un ao outro o bastante ben como para que non o vise en tres anos.
O primeiro está no hook. Chamaba a mediaQuery.addEventListener('change', handler) e nada máis — nunca lía mediaQuery.matches. change dispara cando a preferencia cambia, non cando te subscribes, así que quen tiña o modo escuro activado antes de abrir a páxina levaba a paleta clara e quedaba con ela. A única maneira de ver o tema escuro era cambiar o axuste do sistema coa lapela xa aberta, que é exactamente o que eu facía cada vez que o "probaba".
O segundo é peor. O hook montábao unha soa páxina — o diálogo de axustes —, así que no resto de rutas data-theme nunca se movía do "light" escrito en _document. A paleta escura non é que fose difícil de acadar: é que non se renderizaba nunca. Cando por fin a apliquei a todo o sitio, unha superficie de ventá que levaba aí desde 2023 resultou estar pintando branco sobre branco, porque usaba un token --white-color independente do tema que ninguén vira xamais en escuro.
Mentres tanto, en globals.css había isto:
Esa regra le o sistema operativo directamente, mentres que a paleta da páxina viña de data-theme. Sobre Next.js 15.5, en agosto de 2026 e co sistema en escuro, a páxina informaba data-theme="light" e color-scheme: dark á vez: fondo claro, tokens de texto claros e barras de desprazamento escuras, porque ao navegador dixéraselle que a páxina era escura e o CSS dicía o contrario.
1 — Variables CSS por tema
Esta parte do post orixinal estaba ben e non cambiou. As cores viven como tripletas de canles para poder reutilizalas con alfa, e cada tema redefíneas:
A trampa está no bloque :root. O que quede aí é independente do tema por definición, e un token como --white-color usado como cor de superficie seguirá sendo branco baixo [data-theme='dark']. Se un valor nomea unha cor no canto dun rol, non pinta nada nun compoñente con tema.
2 — color-scheme, a propiedade que arranxa a interface do propio navegador
color-scheme é o que lle di ao navegador que paleta usar para as partes da páxina que pinta el: barras de desprazamento, caixas de verificación, radios, despregables <select>, o subliñado do corrector ortográfico e a cor do lenzo por defecto antes de que cargue o teu CSS. É Baseline —dispoñible en todos os motores— desde xaneiro de 2022, e falta na maioría de titoriais de tema escuro, incluída a primeira versión deste.
Dous detalles importan máis que a propiedade en si:
Decláraa dentro dos bloques [data-theme], non nunha media query. Unha media query segue o sistema operativo. A túa paleta segue o tema aplicado, que despois dun cambio manual non é o mesmo. Ligados ao mesmo selector, non poden separarse.
Tamén arranxa a cor do flash previo ao pintado. Con color-scheme: dark, o navegador pinta o seu lenzo por defecto en escuro no canto de en branco, así que ata o fotograma anterior á túa folla de estilos ten a cor correcta.
3 — Resolver o tema antes do primeiro pintado
O servidor non pode coñecer a preferencia do visitante —non existe cabeceira de petición para prefers-color-scheme—, así que o HTML ten que saír cun valor por defecto e corrixirse no navegador. A corrección ten que ocorrer antes de que se pinte o body, o que descarta useEffect.
Un script síncrono no head conségueo. Bloquea o analizador, que normalmente é algo a evitar, pero aquí son catro bytes e é xusto o obxectivo:
Apuntamentos sobre os detalles, porque cada un está por un motivo:
data-theme="light"queda no marcado. É o recurso para quen teña JavaScript desactivado, e o script sobrescríbeo antes de pintar nada.- O valor gardado compróbase primeiro, para que a escolla explícita gañe ao sistema. Un interruptor que o seguinte cambio do sistema desfai en silencio non é un interruptor.
try/catcharredor delocalStorage. En modo privado e con certas extensións, lelo lanza. Sen a protección, o script enteiro morre e todo o mundo queda en claro.- Vai en
_document, non en_app._documentrenderízase só no servidor e nunca se hidrata, así que mutardocumentElementdesde aí non provoca ningún erro de hidratación.
Metelo no head é ademais o que o fai independente da túa árbore de compoñentes. Ese foi o fallo real no meu caso: o tema aplicábao un hook que só montaba unha páxina. No head execútase en todas as rutas, sen importar que se renderice.
4 — O hook: ler o valor inicial e gardar a escolla
O hook segue facendo falta —para o interruptor, e para seguir o sistema mentres a lapela está aberta—, pero ten que ler matches ao montar no canto de agardar por un evento change:
O handler de change non fai nada a mantenta unha vez hai unha escolla gardada. Sen esa comprobación, a quen escolleu claro daríaselle a volta a próxima vez que o seu portátil pasase a modo noite — o interruptor parecería roto no canto de sobrescrito.
toggleTheme usa a forma funcional de setTheme no canto de ler theme do closure, así non precisa theme na súa lista de dependencias e non pode actuar sobre un valor obsoleto.
5 — Conectar o interruptor
A estas alturas non queda nada especial. O compoñente le o tema actual para a súa etiqueta e chama a toggleTheme:
theme é null no primeiro render — o valor só se resolve dentro do efecto, porque ler localStorage ou matchMedia durante o render faría que o marcado do servidor e o do cliente non coincidisen e sairía un erro de hidratación. Se precisas que o texto sexa correcto tamén no HTML do servidor, etiqueta o control desde o data-theme do documento.
light-dark(), e se compensa
light-dark() mete os dous valores nunha soa declaración e escolle entre eles segundo o esquema de cor en uso do elemento:
É Baseline desde maio de 2024, así que hoxe é usable con recurso alternativo, e quita unha molestia real: a estrutura de dous bloques onde cada token hai que declaralo dúas veces en dous sitios afastados, e engadir un implica lembrarse de engadilo en ambos.
Non migrei esta web a iso, e o motivo non é o soporte de navegadores. light-dark() resólvese contra color-scheme, así que só cobre o par claro/escuro — en canto queiras un terceiro tema, ou unha sobreescritura por sección, volves a redefinir variables baixo un selector. Cun conxunto de tokens xa partido en dous bloques, a reescritura compra CSS máis limpo e nada que o visitante poida ver. Nun proxecto novo comezaría por aí.
O atranco que ninguén menciona: theme-color
<meta name="theme-color"> colorea o cromo do navegador no móbil, e acepta un atributo media:
Ese atributo media le o sistema operativo, e un elemento <meta> non pode ler localStorage. Así que quen sobrescriba o tema terá a páxina nunha paleta e o cromo do navegador na outra. Cambiar o atributo content desde o handler do interruptor é a única forma de mantelos sincronizados, e convén decidir se che importa antes de escribir ese código.
Como comprobar que funciona de verdade
Tres comprobacións, na consola do navegador, co sistema en escuro:
A segunda é a que destapou o fallo desta web: data-theme dicía light mentres colorScheme dicía dark. Se esas dúas algunha vez non coinciden, a paleta e a interface do navegador están a ler fontes distintas.
Todo o código no meu repositorio de github, se che gusta este proxecto, ou se o contido che axudou dalgunha maneira podes recompensarme cunha ⭐️, Grazas!
