Modo oscuro con CSS: prefers-color-scheme, variables y un interruptor

Un modo oscuro bien hecho respeta la preferencia del sistema operativo y, además, deja que la persona lo cambie en tu página. Con variables de CSS se escribe una sola vez; con :has(), el interruptor ni siquiera necesita JavaScript.

El código

<label class="interruptor">
  <input type="checkbox" id="oscuro"> Modo oscuro
</label>
<main>
  <h1>Mi página</h1>
  <p>El texto y el fondo cambian juntos, y los controles del formulario también.</p>
  <input placeholder="Un campo de texto">
</main>
:root {
  color-scheme: light;
  --fondo: #ffffff;
  --texto: #33475b;
  --titulo: #0f1f33;
}

/* 1) La preferencia del sistema */
@media (prefers-color-scheme: dark) {
  :root {
    color-scheme: dark;
    --fondo: #0f1f33;
    --texto: #c9d6e4;
    --titulo: #ffffff;
  }
}

/* 2) El interruptor de la página manda sobre el sistema */
:root:has(#oscuro:checked) {
  color-scheme: dark;
  --fondo: #0f1f33;
  --texto: #c9d6e4;
  --titulo: #ffffff;
}

body {
  background: var(--fondo);
  color: var(--texto);
  font-family: system-ui, sans-serif;
}
h1 { color: var(--titulo); }

Marca la casilla: el fondo pasa de rgb(255, 255, 255) a rgb(15, 31, 51), comprobado en los tres navegadores. Si tu sistema ya está en modo oscuro, la página empieza oscura sin tocar nada.

Las tres piezas

  1. Variables. Todos los colores salen de unas pocas propiedades personalizadas (--fondo, --texto…). Cambiar de tema es cambiar esas variables; el resto de la hoja no se toca.
  2. prefers-color-scheme: dark se cumple cuando el sistema operativo está en modo oscuro. Es lo mínimo: con esto la página ya respeta la preferencia de cada quien.
  3. color-scheme avisa al navegador de que la página está en oscuro, y él adapta lo que pinta por su cuenta: los campos de formulario, las barras de desplazamiento, el fondo antes de que cargue tu CSS (queda dark al activar el interruptor). Sin esta línea, verás campos blancos sobre una página oscura.

El interruptor, sin JavaScript

:root:has(#oscuro:checked) se lee «la raíz del documento, si contiene una casilla #oscuro marcada». Como va después de la media query, manda sobre la preferencia del sistema. :has() funciona en Chrome 105, Safari 15.4 y Firefox 121 en adelante.

Lo que este interruptor no hace es recordar la elección: al recargar, la casilla vuelve a estar desmarcada. Para guardarla hacen falta tres líneas de JavaScript con localStorage, que pongan el atributo checked al cargar.

En oscuro, no uses negro puro con blanco puro: el contraste extremo cansa y hace que el texto «vibre». Un azul muy oscuro con un gris claro, como en el ejemplo, se lee mejor y sigue pasando de sobra el contraste mínimo de 4.5 a 1.

Cada fragmento de HTML de esta página pasó por el Nu Html Checker 26.11.4 (el validador del W3C) y cada resultado se pintó y se midió en Chromium, Firefox y WebKit antes de publicarla. Prueba tus cambios en el editor de HTML y CSS o aprende desde cero con el curso.