prefers-color-scheme: cómo usarlo bien en CSS
prefers-color-scheme es la media feature de CSS que informa qué esquema de
color ha pedido el usuario — light o dark — según lo configurado en el
sistema operativo o el navegador. Usarlo bien significa usarlo solo como valor
por defecto: las custom properties definen el tema claro en :root, la media
query invierte esos valores por defecto cuando el sistema prefiere el oscuro, y
un atributo data-theme que activa un interruptor manual anula ambos. La
query por sí sola no es una implementación, porque la mayoría de los productos
reales también necesitan el interruptor — y un interruptor no puede anular una
media query desde CSS.
Esta es la referencia de implementación de la maquinaria de conmutación de nuestra guía de modo oscuro: la query, el patrón de anulación y los cuatro tropiezos que llenan los foros de soporte.
¿Qué lee realmente la media query?
La preferencia declarada del usuario, heredada del entorno: el ajuste de apariencia del sistema operativo (o una anulación a nivel de navegador donde exista) aflora en CSS como una media feature, exactamente igual que el ancho del viewport. La forma más simple:
@media (prefers-color-scheme: dark) {
/* estilos para usuarios que pidieron el oscuro */
}
No hay JavaScript de por medio, y la query es dinámica: cambia el ajuste del sistema operativo y los estilos coincidentes se aplican de inmediato. Los valores de la feature y las notas de compatibilidad están en la página de referencia de MDN. Lo que la query no conoce es tu interfaz — informa sobre el sistema, no sobre el botón de tu cabecera.
¿Por qué un interruptor manual necesita anular la media query?
Porque todo producto real acaba con tres estados, no dos: seguir al sistema, claro forzado y oscuro forzado. Un usuario cuyo sistema operativo va en oscuro puede querer igualmente tu app en claro (las sesiones largas de lectura son una razón común), y viceversa. La media query solo puede expresar el primer estado — así que el patrón robusto superpone un atributo encima:
:root {
color-scheme: light dark;
--background: oklch(0.94 0.008 262.9);
--text-primary: oklch(0.28 0.02 262.9);
}
/* por defecto: seguir al sistema */
@media (prefers-color-scheme: dark) {
:root {
--background: oklch(0.22 0.015 262.9);
--text-primary: oklch(0.94 0.008 262.9);
}
}
/* estados forzados: el atributo gana sobre el valor por defecto */
[data-theme="light"] {
color-scheme: light;
--background: oklch(0.94 0.008 262.9);
--text-primary: oklch(0.28 0.02 262.9);
}
[data-theme="dark"] {
color-scheme: dark;
--background: oklch(0.22 0.015 262.9);
--text-primary: oklch(0.94 0.008 262.9);
}
body { background: var(--background); color: var(--text-primary); }
Léelo como una cascada de valores por defecto: :root dice claro, la media
query dice «a menos que el sistema prefiera el oscuro», el atributo dice «a
menos que el usuario nos haya indicado otra cosa». Sin ningún atributo
presente, el sitio sigue al sistema; con uno, gana la elección del usuario
dentro de la app. La parte de JavaScript es una línea más el almacenamiento:
const saved = localStorage.getItem("theme"); // "light" | "dark" | null
if (saved) document.documentElement.dataset.theme = saved;
null — el tercer estado — significa «no pongas ningún atributo y deja que
decida la media query». Guardar "system" de forma explícita y quitar el
atributo funciona igual.
Ese es el ejemplo completo: unas veinticinco líneas de CSS y tres de JavaScript cubren el seguimiento del sistema, ambos estados forzados y los controles de formulario nativos. Los valores en sí vienen en pares — uno por tema, por rol — que es donde la capa de tokens le entrega sus datos al patrón.
Mira un conjunto de roles con los valores de ambos temas en Scale Composer — cada rol semántico con su valor claro y oscuro uno al lado del otro, listo para pegar en los dos bloques de arriba.

¿Cuáles son los tropiezos que llenan los foros de soporte?
Los controles de formulario ignoran tu tema. Tu CSS pinta la página en
oscuro, pero las barras de desplazamiento, las casillas de verificación y los
desplegables se quedan en claro — porque los pinta el navegador, y a él nadie
le avisó. El arreglo es la línea que ya está en el patrón de arriba:
color-scheme: light dark le dice al navegador qué esquemas admite la página, y
los bloques de estado forzado lo acotan para que los controles nativos sigan la
anulación, no solo la query.
El destello del tema equivocado. Un usuario que prefiere el oscuro carga la
página, ve un destello blanco y luego se aplica el tema. Ocurre cuando la
anulación guardada la aplica un script que se ejecuta después del primer
pintado. El arreglo honesto es un script inline en el <head> — antes del
render que depende de la hoja de estilos, no diferido, no empaquetado — que lea
localStorage y ponga el atributo. Bloquea el render por diseño; limítalo a
esas pocas líneas.
Las imágenes y los embeds no siguen el tema. Un elemento <picture> puede
cambiar de fuente según el tema
con media="(prefers-color-scheme: dark)", pero eso sigue al sistema, no a tu
anulación data-theme — una asimetría conocida del patrón de atributo. Los
iframes resuelven la query en su propio contexto. Para el contenido que debe
seguir el interruptor dentro de la app, CSS (o un pequeño script) tiene que
hacer el cambio.
El cambio anima todo. Un transition: all genérico sobre los elementos
afectados por el tema convierte el cambio en un lento fundido cruzado de cada
color en pantalla. Acota las transiciones a las propiedades que las necesiten, o
desactívalas brevemente mientras se cambia.
¿La query hace bueno al tema oscuro?
No — solo responde qué tema mostrar. Si vale la pena cambiar al tema oscuro lo decide la paleta detrás de las custom properties: un conjunto oscuro derivado con su propia curva de luminosidad y un impulso de croma se lee como un tema diseñado, mientras que unos valores claros invertidos se leen como el negativo de uno. La query es enrutamiento; los valores son el producto. Derivar esos valores es el tema del resto de este hub.
Comprueba el patrón con tus propios valores
Los dos bloques del patrón valen solo lo que valen los pares con los que los rellenas. Carga una paleta y comprueba el ancla de cada rol bajo ambos temas — los mismos nombres de rol resolviéndose por tema, los mínimos de contraste vueltos a comprobar en cada lado, para que los valores que pegues en la media query y en el bloque de atributo sean ya pares verificados.