Actualizado 11 de julio de 2026

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.

Una lista de roles semánticos en Scale Composer que muestra los valores claros y oscuros en columnas paralelas para los mismos nombres de rol

¿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.

Seguir leyendo

  • Tokens semánticos para el cambio de tema

    Los design tokens de modo oscuro explicados: roles semánticos que contienen un valor por tema, por qué el mapeo oscuro no es un espejo, y el CSS que cambia de tema.

  • El modo oscuro no es inversión

    El diseño de modo oscuro empieza donde falla la inversión: lógica de rol volteada, elevación rota, color drenado. Qué significa derivar una paleta oscura de las mismas semillas.