Saltar al contenido
modo-oscuro.css · devschool

Modo oscuro en CSS

Lección 32 de 50 · 10 min de lectura · Actualizado el

En esta lección
  1. Detectar la preferencia: prefers-color-scheme
  2. Avisar al navegador: color-scheme
  3. Organizar los colores con variables
  4. La función light-dark()
  5. Un botón para cambiar de tema
  6. Imágenes, logos y sombras
  7. Contraste en los dos temas
  8. Errores frecuentes
  9. Resumen

Cada vez más gente usa el móvil y el ordenador en modo oscuro: cansa menos la vista de noche, ahorra batería en pantallas OLED y a muchas personas simplemente les gusta más. Una web que aparece blanca y deslumbrante en un sistema oscuro se nota descuidada. En esta lección aprenderás a detectar la preferencia del usuario, a avisar al navegador de que tu web admite los dos temas, a organizar los colores con variables, a usar la función light-dark(), a añadir un botón para que el usuario elija y a cuidar imágenes, sombras y contraste.

En la lección de variables CSS ya viste la idea básica (redefinir variables en un tema oscuro). Aquí vas a montar un sistema completo y sin fallos.

Detectar la preferencia: prefers-color-scheme

El sistema operativo guarda si el usuario prefiere tema claro u oscuro, y CSS puede consultarlo con una media query:

body {
  background: white;
  color: #222;
}

@media (prefers-color-scheme: dark) {
  body {
    background: #121212;
    color: #e6e6e6;
  }
}

Los valores posibles son light y dark. Si el usuario cambia el tema del sistema, la página se actualiza al instante, sin recargar.

Para probarlo sin tocar tu sistema, abre DevTools, pulsa Ctrl + Shift + P (Cmd + Shift + P en Mac), escribe “dark” y elige Emulate CSS prefers-color-scheme: dark.

Avisar al navegador: color-scheme

Si solo cambias tus colores, notarás detalles raros en modo oscuro: los campos de formulario siguen blancos, las barras de desplazamiento son claras y el fondo parpadea en blanco al cargar. Eso es porque el navegador no sabe que tu web admite tema oscuro.

La propiedad color-scheme se lo dice:

:root {
  color-scheme: light dark;
}

Con esto, el navegador adapta sus propios elementos al tema activo: fondo por defecto, color del texto, <input>, <select>, casillas, barras de scroll… Solo con esta línea, una página sin estilos ya tiene un modo oscuro aceptable.

También existe como etiqueta en el <head>, que el navegador lee antes de descargar el CSS y así evita el destello blanco:

<meta name="color-scheme" content="light dark">
ValorSignificado
lightMi web solo es clara (por defecto)
darkMi web solo es oscura
light darkAdmito los dos; usa el que prefiera el usuario

Organizar los colores con variables

El error típico es repetir medio CSS dentro de la media query, cambiando color a color. Lo que funciona es separar qué color es cada cosa (su función) de qué valor tiene. Define variables con nombres de función, no de color:

:root {
  --fondo: #f7f7f4;
  --superficie: #ffffff;      /* tarjetas, menús, modales */
  --texto: #1f2328;
  --texto-suave: #5b6270;
  --borde: #d8dbe0;
  --principal: #1f5fc9;
}

@media (prefers-color-scheme: dark) {
  :root {
    --fondo: #16181d;
    --superficie: #22252c;
    --texto: #e6e6e6;
    --texto-suave: #a3a9b5;
    --borde: #3a3f48;
    --principal: #7aa7f5;
  }
}

body    { background: var(--fondo); color: var(--texto); }
.tarjeta { background: var(--superficie); border: 1px solid var(--borde); }
a, h1   { color: var(--principal); }

El resto del CSS solo usa variables y no sabe nada de temas. Si mañana añades un tercer tema (alto contraste, sepia), basta con otro bloque de variables.

Consejo: llama a las variables por su papel (--fondo, --texto-suave), nunca por su valor (--blanco, --gris-claro). En modo oscuro, --blanco acabaría valiendo un gris casi negro, y eso confunde a cualquiera.

Elegir los colores oscuros

Un tema oscuro no es invertir los colores:

  • No uses negro puro (#000) de fondo ni blanco puro de texto: el contraste extremo produce un efecto de “vibración” en las letras. Un gris muy oscuro (#121212 a #1e1e1e) y un texto casi blanco son más cómodos.
  • La profundidad se marca con luz, no con sombras: en oscuro, las superficies que están “más arriba” (una tarjeta, un menú) son un poco más claras que el fondo.
  • Desatura los colores de marca. Un azul intenso que queda bien sobre blanco resulta chillón sobre negro. Usa una versión más clara y menos saturada.

La función light-dark()

Escribir cada color dos veces, en dos bloques separados, obliga a saltar de un sitio a otro. La función light-dark() permite poner los dos valores juntos:

:root {
  color-scheme: light dark;

  --fondo: light-dark(#f7f7f4, #16181d);
  --texto: light-dark(#1f2328, #e6e6e6);
  --principal: light-dark(#1f5fc9, #7aa7f5);
}

El primer valor se usa en tema claro y el segundo en oscuro. ¿Cómo sabe cuál toca? Mira el color-scheme del elemento. Por eso es imprescindible declarar color-scheme: light dark: sin él, light-dark() siempre devuelve el primer valor.

Ventajas: los dos colores quedan uno al lado del otro, no necesitas media query y, como verás enseguida, el botón de cambiar tema es más sencillo. Funciona en todos los navegadores actuales desde 2024. Su única limitación es que solo admite colores (no sirve para cambiar una imagen o un tamaño).

Un botón para cambiar de tema

Seguir la preferencia del sistema está bien, pero mucha gente quiere elegir en cada web. La forma habitual es un atributo data-theme en <html> que fuerza un tema. Con light-dark(), solo tienes que cambiar el color-scheme:

:root {
  color-scheme: light dark;            /* por defecto: lo que diga el sistema */
}

:root[data-theme="light"] { color-scheme: light; }
:root[data-theme="dark"]  { color-scheme: dark; }

Ahora un poco de JavaScript para el botón. Guarda la elección en localStorage para recordarla en la siguiente visita (lo tienes en localStorage):

<button id="cambiar-tema" type="button">Cambiar tema</button>
const raiz = document.documentElement;
const boton = document.querySelector('#cambiar-tema');

// Al cargar: aplicar la elección guardada, si la hay
const guardado = localStorage.getItem('tema');
if (guardado) {
  raiz.dataset.theme = guardado;
}

boton.addEventListener('click', () => {
  // ¿Qué tema se ve ahora mismo? El elegido o, si no hay, el del sistema
  const sistemaOscuro = window.matchMedia('(prefers-color-scheme: dark)').matches;
  const actual = raiz.dataset.theme ?? (sistemaOscuro ? 'dark' : 'light');
  const nuevo = actual === 'dark' ? 'light' : 'dark';
  raiz.dataset.theme = nuevo;
  localStorage.setItem('tema', nuevo);
});

Paso a paso:

  • raiz.dataset.theme = 'dark' escribe el atributo data-theme="dark" en <html>, y la regla :root[data-theme="dark"] entra en juego.
  • window.matchMedia('(prefers-color-scheme: dark)').matches es la misma media query de CSS, pero desde JavaScript: vale true si el sistema está en oscuro.
  • Si el usuario todavía no ha elegido, raiz.dataset.theme es undefined y el operador ?? usa el tema del sistema. Así, el primer clic siempre cambia lo que se ve.

Cuidado: si el script que lee localStorage va al final de la página, durante un instante se ve el tema equivocado (un destello). Pon ese trocito en un <script> pequeño dentro del <head>, antes del CSS, para aplicar el tema guardado antes de pintar nada.

Sin light-dark(): con variables y data-theme

Si no usas light-dark(), la idea es la misma pero tienes que repetir las variables oscuras para la media query y para el atributo:

:root { --fondo: #f7f7f4; --texto: #1f2328; }

:root[data-theme="dark"] { --fondo: #16181d; --texto: #e6e6e6; }

@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) { --fondo: #16181d; --texto: #e6e6e6; }
}

El :not([data-theme="light"]) evita que el sistema oscuro “gane” cuando el usuario ha elegido claro a propósito. Es más largo y fácil de desincronizar: por eso hoy se prefiere light-dark().

Imágenes, logos y sombras

Los colores no son lo único que cambia.

Imágenes distintas para cada tema

Un logo negro sobre fondo transparente desaparece en modo oscuro. El elemento <picture> puede elegir imagen según la preferencia del sistema:

<picture>
  <source srcset="logo-claro.svg" media="(prefers-color-scheme: dark)">
  <img src="logo-oscuro.svg" alt="Biblioteca Norte">
</picture>

Ojo: el nombre puede despistar. En modo oscuro se carga el logo de color claro, para que se vea sobre el fondo oscuro. Esta técnica sigue al sistema, no al botón data-theme. Si usas botón, haz el logo en SVG con fill="currentColor" y así toma el color del texto en cada tema.

Fotos menos deslumbrantes

Una foto muy luminosa en una página oscura molesta. Bájale un poco el brillo:

@media (prefers-color-scheme: dark) {
  img:not([src$=".svg"]) {
    filter: brightness(0.85);
  }
}

No inviertas fotos con filter: invert(1): las caras y los paisajes quedan como un negativo. La inversión solo sirve para algunos iconos o diagramas en blanco y negro. Tienes más sobre filter en filtros y efectos.

Sombras

Sobre un fondo casi negro, una sombra gris suave no se ve. En oscuro, haz las sombras más oscuras y opacas, o sustitúyelas por un borde sutil o una superficie más clara:

.tarjeta {
  box-shadow: 0 2px 8px light-dark(rgb(0 0 0 / 0.12), rgb(0 0 0 / 0.5));
}

Contraste en los dos temas

Un modo oscuro mal hecho suele fallar en contraste: texto gris sobre fondo gris. Las pautas de accesibilidad (WCAG) piden una relación de contraste de al menos 4,5:1 para el texto normal y 3:1 para el texto grande y los iconos. Debes comprobarlo en los dos temas, porque un color que pasa en claro puede no pasar en oscuro.

Para comprobarlo:

  • En DevTools, selecciona un texto y, en el panel Styles, pulsa el cuadradito de color: el selector muestra la relación de contraste y si cumple.
  • El panel Lighthouse señala los textos con poco contraste de toda la página.

Fíjate especialmente en el color principal: un azul #1f5fc9 tiene buen contraste sobre blanco, pero sobre #16181d se lee mal. Por eso en el tema oscuro se usa uno más claro. Tienes más sobre contraste en CSS accesible.

Errores frecuentes

  • Olvidar color-scheme. Los formularios y las barras de scroll se quedan claros, y light-dark() no funciona.
  • Nombres de variables por color (--blanco), que en modo oscuro mienten.
  • Colores sueltos fuera de las variables. Un color: #333 escondido en un componente se vuelve ilegible en oscuro. Busca colores “a pelo” en tu CSS.
  • Negro puro y blanco puro. Cansan más que un gris muy oscuro y un blanco roto.
  • Ignorar la elección del usuario. Si eligió claro con el botón, el sistema oscuro no debe imponerse.
  • El destello del tema equivocado al cargar, por aplicar el tema guardado demasiado tarde.
  • No revisar el contraste del modo oscuro.

Resumen

HerramientaPara quéEjemplo
prefers-color-schemeDetectar el tema del sistema@media (prefers-color-scheme: dark)
color-schemeQue el navegador adapte sus elementos:root { color-scheme: light dark; }
<meta name="color-scheme">Lo mismo, antes de cargar el CSScontent="light dark"
Variables por funciónUn solo lugar para cada color--fondo, --texto, --superficie
light-dark()Color claro y oscuro juntoslight-dark(#fff, #16181d)
data-theme + JSTema elegido por el usuario:root[data-theme="dark"] { color-scheme: dark; }
<picture> + mediaImagen distinta en oscuromedia="(prefers-color-scheme: dark)"
Contraste 4,5:1Texto legible en los dos temasComprobar en DevTools

Con los colores bajo control, es el momento de que los cambios de estado se vean suaves. Sigue con transiciones.

Pruébalo tú

Cambia el código y pulsa Ejecutar (o Ctrl + Enter).

modo-oscuro.html
Resultado

Pon a prueba lo que has aprendido

[CSS] ¿Qué hace :root { color-scheme: light dark; }?

[CSS] ¿Qué devuelve color: light-dark(#111, #eee) en un navegador cuyo sistema está en modo oscuro (con color-scheme: light dark)?

[CSS] ¿Por qué se desaconseja el negro puro (#000) de fondo con texto blanco puro?

[CSS] ¿Cómo evitar el destello del tema equivocado al cargar si guardas la elección en localStorage?

¿Te ha quedado claro? Márcala y verás tu progreso en el explorador.