Temas con light-dark() y color-scheme
Los tres estados de un conmutador de tema, por qué light-dark() no funciona sin color-scheme, y la arquitectura de tema que no produce un destello al cargar.
Un conmutador de tema parece un problema de dos estados y son tres, y esa confusión es el origen de la mayoría de las implementaciones rotas que verás. Encima, la función que la plataforma da para resolverlo —light-dark()— tiene una dependencia que no es evidente en su firma: no funciona sola. Esta lección desmonta las dos cosas y termina con la arquitectura mínima que soporta los tres estados sin destello y sin duplicar la capa de tokens.
- Distinguir los tres estados reales de la preferencia de tema.
- Usar
light-dark()sabiendo contra qué resuelve y cuándo devuelve la rama equivocada. - Elegir entre
light-dark()y redeclarar semánticos, con criterio. - Construir un conmutador que no produzca destello ni desincronice el estado.
Tres estados, no dos
La preferencia de tema de un usuario tiene tres valores posibles, y confundir el tercero con uno de los dos primeros es el fallo de diseño más común:
- Claro explícito: el usuario ha elegido claro en tu producto.
- Oscuro explícito: el usuario ha elegido oscuro en tu producto.
- Sin elección: el usuario no ha tocado nada, y lo correcto es seguir su sistema operativo.
El tercero es el estado por defecto y es donde está la inmensa mayoría de tus usuarios. Un conmutador de dos posiciones no puede representarlo, y por eso las implementaciones de dos posiciones acaban guardando una elección que el usuario nunca hizo: el primer clic escribe “claro” en el almacenamiento, y a partir de ahí el usuario deja de seguir su sistema para siempre, incluso si vuelve a poner el conmutador donde estaba. Un conmutador correcto tiene tres posiciones, o dos posiciones y una forma de volver a “automático”.
En CSS, los tres estados se expresan así:
:root { color-scheme: light dark; } /* automatico: sigue al sistema */
[data-tema="claro"] { color-scheme: light; } /* claro explicito */
[data-tema="oscuro"] { color-scheme: dark; } /* oscuro explicito */
Sin atributo, manda el sistema. Con atributo, manda el atributo. Un solo mecanismo para los tres estados.
light-dark() y su dependencia oculta
light-dark() toma dos valores y devuelve uno según el esquema de color activo en ese elemento:
--superficie: light-dark(oklch(0.98 0.004 265), oklch(0.22 0.012 265));
--texto: light-dark(oklch(0.22 0.012 265), oklch(0.95 0.004 265));
La dependencia que no se ve en la firma es esta: el esquema activo lo determina la propiedad color-scheme, no la media query prefers-color-scheme. Si no has declarado color-scheme en ningún ancestro, el esquema activo es light y light-dark() devuelve siempre la primera rama, aunque el sistema operativo del usuario esté en oscuro y prefers-color-scheme diga lo contrario.
Ese es el fallo número uno con esta función, y es especialmente engañoso porque en las pruebas rápidas parece que “no funciona en Firefox” o “no funciona a veces”, cuando lo que ocurre es que falta la declaración. La regla es simple: color-scheme primero, light-dark() después.
La segunda consecuencia de esa dependencia es útil: como color-scheme se puede declarar por elemento y se hereda, light-dark() resuelve por subárbol. Un panel con color-scheme: dark dentro de una página clara hace que todos los light-dark() de su interior devuelvan la rama oscura, sin duplicar ninguna regla y sin ninguna clase de tema. Eso es difícil de conseguir de otra forma.
color-scheme no solo alimenta a light-dark(): cambia también el lienzo por defecto del documento, las barras de scroll y el dibujo nativo de todos los controles de formulario. Ese efecto sobre los controles, que es el que evita que un formulario parezca un recorte blanco pegado sobre una página oscura, está desarrollado en la lección del nivel 49.
light-dark() o redeclarar semánticos
Hay dos formas de construir un tema y las dos son correctas en contextos distintos. Conviene elegir a conciencia porque mezclarlas produce sistemas donde nadie sabe de dónde viene un color.
Con light-dark() en la declaración del semántico. Cada token contiene sus dos valores. La ventaja es que los dos valores están juntos y se leen en la misma línea: revisar la coherencia del tema es leer la capa de tokens de arriba abajo. La desventaja es que solo funciona con valores, no con estructuras: no puedes cambiar una sombra por un borde, ni añadir un token que solo exista en un tema.
@layer tokens {
:root {
color-scheme: light dark;
--superficie: light-dark(var(--gris-50), var(--gris-900));
--texto: light-dark(var(--gris-900), var(--gris-50));
--borde: light-dark(var(--gris-200), var(--gris-700));
}
}
Redeclarando los semánticos por tema. Un bloque por tema que reasigna los semánticos, tal como se describió en la lección de implementación. La ventaja es que puedes cambiar lo que quieras, incluidas propiedades que no son colores: sombras, opacidades, grosores de borde. La desventaja es la duplicación y el riesgo de que un token nuevo se añada a un tema y se olvide en el otro.
@layer tokens {
:root { color-scheme: light dark; }
:root, [data-tema="claro"] {
--superficie: var(--gris-50);
--elevacion-1: 0 1px 2px oklch(0 0 0 / 0.08);
}
@media (prefers-color-scheme: dark) {
:root:not([data-tema="claro"]) {
--superficie: var(--gris-900);
--elevacion-1: 0 0 0 1px oklch(1 0 0 / 0.08);
}
}
[data-tema="oscuro"] {
--superficie: var(--gris-900);
--elevacion-1: 0 0 0 1px oklch(1 0 0 / 0.08);
}
}
Fíjate en --elevacion-1: en claro es una sombra, en oscuro es un borde luminoso. Eso es un cambio estructural y light-dark() no lo puede expresar. Es también un anticipo de por qué el modo oscuro no es una inversión, que es el tema de la lección siguiente.
La recomendación práctica: light-dark() para los colores, redeclaración para lo demás. Los dos mecanismos conviven bien si la frontera está clara.
El conmutador sin destello
El destello ocurre cuando el navegador pinta el primer fotograma con el tema equivocado y lo corrige después. Es inevitable si el tema depende de JavaScript que se ejecuta al cargar, porque para entonces ya se ha pintado algo.
La arquitectura que no parpadea tiene tres piezas y hay que poner las tres.
Primera: la meta etiqueta. El navegador la lee antes de cualquier hoja de estilos y ajusta el color del lienzo antes del primer paint.
<meta name="color-scheme" content="light dark">
Segunda: el caso por defecto sin JavaScript. Para el usuario en estado “sin elección” —la mayoría— el tema correcto sale de color-scheme: light dark y de la media query. Cero JavaScript, cero destello, funciona aunque el script falle.
Tercera: un script bloqueante mínimo, solo para la elección explícita. Va en el head, sin defer, y no hace nada más que leer el almacenamiento y poner el atributo.
<script>
const t = localStorage.getItem('tema');
if (t) document.documentElement.dataset.tema = t;
</script>
Ese script se ejecuta antes de que se construya el body y por tanto antes del primer paint. Es una de las poquísimas situaciones donde un script bloqueante en la cabecera es la decisión correcta, precisamente porque su coste es de microsegundos y su alternativa es un destello visible.
Y una advertencia sobre el conmutador en sí: el estado que muestra el control debe leerse del atributo, no de una variable de JavaScript, para que no pueda desincronizarse. Si el usuario está en automático, el control debe decir “automático”, no adivinar entre claro y oscuro.
La confusión entre estas dos es la fuente de casi todos los bugs de tema que solo se dan en un dispositivo. prefers-color-scheme es una media query: pregunta qué prefiere el usuario en su sistema operativo. Es una lectura del entorno, es global para el documento y no la puedes cambiar. color-scheme es una propiedad: declara en qué esquema está funcionando este elemento y su subárbol. Es una afirmación tuya, es local y se hereda. Las dos suelen coincidir, y por eso pasan años sin que la diferencia se note. Deja de coincidir exactamente en el caso que rompe todo: cuando el usuario ha elegido explícitamente ir contra su sistema. Alguien con el sistema en oscuro que ha puesto tu producto en claro tiene prefers-color-scheme: dark y color-scheme: light a la vez, y eso no es un estado inconsistente: es el estado correcto. Todo lo que hayas escrito dentro de @media (prefers-color-scheme: dark) se aplicará a ese usuario aunque tu producto esté en claro. El resultado son interfaces híbridas —fondo claro, bordes de la paleta oscura, controles nativos oscuros— que el equipo no reproduce nunca porque casi nadie prueba la combinación cruzada. La regla que evita la clase entera de bugs es corta y conviene aplicarla sin excepciones: usa prefers-color-scheme una sola vez, en la capa de tokens, para decidir el valor por defecto; a partir de ahí, todo lo demás depende de color-scheme y de tus semánticos, nunca de la media query. Si te encuentras escribiendo una segunda media query de esquema en un componente, ese componente está leyendo el entorno en lugar de leer tu sistema, y va a mentir en cuanto alguien cambie el tema a mano.