Modo oscuro en CSS
En esta lección
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">
| Valor | Significado |
|---|---|
light | Mi web solo es clara (por defecto) |
dark | Mi web solo es oscura |
light dark | Admito 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,--blancoacabarí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 (#121212a#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 atributodata-theme="dark"en<html>, y la regla:root[data-theme="dark"]entra en juego.window.matchMedia('(prefers-color-scheme: dark)').matcheses la misma media query de CSS, pero desde JavaScript: valetruesi el sistema está en oscuro.- Si el usuario todavía no ha elegido,
raiz.dataset.themeesundefinedy el operador??usa el tema del sistema. Así, el primer clic siempre cambia lo que se ve.
Cuidado: si el script que lee
localStorageva 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, ylight-dark()no funciona. - Nombres de variables por color (
--blanco), que en modo oscuro mienten. - Colores sueltos fuera de las variables. Un
color: #333escondido 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
| Herramienta | Para qué | Ejemplo |
|---|---|---|
prefers-color-scheme | Detectar el tema del sistema | @media (prefers-color-scheme: dark) |
color-scheme | Que el navegador adapte sus elementos | :root { color-scheme: light dark; } |
<meta name="color-scheme"> | Lo mismo, antes de cargar el CSS | content="light dark" |
| Variables por función | Un solo lugar para cada color | --fondo, --texto, --superficie |
light-dark() | Color claro y oscuro juntos | light-dark(#fff, #16181d) |
data-theme + JS | Tema elegido por el usuario | :root[data-theme="dark"] { color-scheme: dark; } |
<picture> + media | Imagen distinta en oscuro | media="(prefers-color-scheme: dark)" |
| Contraste 4,5:1 | Texto legible en los dos temas | Comprobar 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).
Pon a prueba lo que has aprendido
¿Te ha quedado claro? Márcala y verás tu progreso en el explorador.