StackPractices
intermediate Por Mathias Paulenko

Modo Oscuro con prefers-color-scheme y Variables CSS

Cómo implementar el modo oscuro usando el media query CSS prefers-color-scheme, custom properties y un toggle manual con persistencia en localStorage.

Temas: frontend

Descripción General

El media query CSS prefers-color-scheme detecta si el usuario prefiere el modo claro u oscuro a nivel del sistema operativo. Combinado con CSS custom properties, puedes cambiar todos los colores, bordes y sombras sobrescribiendo unas pocas variables. Un toggle manual con localStorage permite al usuario sobreescribir la preferencia del sistema, y un pequeño script inline evita el destello del tema incorrecto al cargar la página.

La receta se reduce a tres piezas: tokens de color semánticos (las variables), una forma de elegir qué conjunto de tokens está activo (media query, atributo data-theme, o ambos), y una capa de persistencia (localStorage) que recuerda la elección explícita del usuario. Si resuelves eso, lo demás es decoración — cambio de iconos, variantes de imágenes, config de Tailwind.

Cuándo Usar

  • Cualquier sitio web que deba respetar la preferencia de modo oscuro del SO del usuario
  • Aplicaciones donde los usuarios pasan mucho tiempo y la fatiga visual importa
  • Sitios con bloques de código — el modo oscuro reduce el deslumbramiento para desarrolladores
  • Sitios con mucho contenido donde el modo oscuro mejora la legibilidad en ambientes con poca luz

Cuándo NO Usar

  • Esquemas de color impuestos por la marca que no deben cambiar
  • Hojas de estilo de impresión — el modo oscuro no aplica a páginas impresas
  • Navegadores antiguos sin soporte de prefers-color-scheme (IE11, Safari antiguo)

Solución

Modo oscuro solo con CSS y prefers-color-scheme

:root {
  --color-bg: #ffffff;
  --color-surface: #f9fafb;
  --color-text: #111827;
  --color-text-muted: #6b7280;
  --color-border: #e5e7eb;
  --color-accent: #3b82f6;
  --color-accent-hover: #2563eb;
  --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
}

@media (prefers-color-scheme: dark) {
  :root {
    --color-bg: #0f172a;
    --color-surface: #1e293b;
    --color-text: #e2e8f0;
    --color-text-muted: #94a3b8;
    --color-border: #334155;
    --color-accent: #3b82f6;
    --color-accent-hover: #60a5fa;
    --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
  }
}

body {
  background: var(--color-bg);
  color: var(--color-text);
}

.card {
  background: var(--color-surface);
  border: 1px solid var(--color-border);
  box-shadow: var(--shadow-sm);
}

a {
  color: var(--color-accent);
}

a:hover {
  color: var(--color-accent-hover);
}

Toggle manual con el atributo data-theme

:root,
[data-theme="light"] {
  --color-bg: #ffffff;
  --color-surface: #f9fafb;
  --color-text: #111827;
  --color-text-muted: #6b7280;
  --color-border: #e5e7eb;
  --color-accent: #3b82f6;
}

[data-theme="dark"] {
  --color-bg: #0f172a;
  --color-surface: #1e293b;
  --color-text: #e2e8f0;
  --color-text-muted: #94a3b8;
  --color-border: #334155;
  --color-accent: #60a5fa;
}
function setTheme(theme) {
  document.documentElement.setAttribute("data-theme", theme);
  localStorage.setItem("theme", theme);
}

function toggleTheme() {
  const current = document.documentElement.getAttribute("data-theme");
  setTheme(current === "dark" ? "light" : "dark");
}

// Inicializar desde localStorage o la preferencia del sistema
const saved = localStorage.getItem("theme");
if (saved) {
  setTheme(saved);
} else {
  const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
  setTheme(prefersDark ? "dark" : "light");
}

Evitar el destello de tema incorrecto (FOUC)

Coloca este script en el <head> antes de que cargue el CSS:

<head>
  <script>
    (function () {
      const saved = localStorage.getItem("theme");
      const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
      const theme = saved || (prefersDark ? "dark" : "light");
      document.documentElement.setAttribute("data-theme", theme);
    })();
  </script>
  <link rel="stylesheet" href="/styles.css" />
</head>

Componente de botón toggle

<button id="theme-toggle" aria-label="Toggle theme">
  <svg class="sun-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor">
    <circle cx="12" cy="12" r="5" />
    <line x1="12" y1="1" x2="12" y2="3" />
    <line x1="12" y1="21" x2="12" y2="23" />
    <line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
    <line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
  </svg>
  <svg class="moon-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor">
    <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
  </svg>
</button>

<script>
  const toggle = document.getElementById("theme-toggle");
  toggle.addEventListener("click", toggleTheme);

  // Actualizar la visibilidad del icono
  function updateIcon() {
    const theme = document.documentElement.getAttribute("data-theme");
    toggle.classList.toggle("dark", theme === "dark");
  }
  updateIcon();
  toggle.addEventListener("click", updateIcon);
</script>

<style>
  .sun-icon { display: block; }
  .moon-icon { display: none; }
  #theme-toggle.dark .sun-icon { display: none; }
  #theme-toggle.dark .moon-icon { display: block; }
</style>

Escuchar los cambios de preferencia del sistema

window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", (e) => {
  const saved = localStorage.getItem("theme");
  if (!saved) {
    document.documentElement.setAttribute("data-theme", e.matches ? "dark" : "light");
  }
});

Modo oscuro con Tailwind CSS v4

@theme {
  --color-bg: #ffffff;
  --color-surface: #f9fafb;
  --color-text: #111827;
}

@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));

[data-theme="dark"] {
  --color-bg: #0f172a;
  --color-surface: #1e293b;
  --color-text: #e2e8f0;
}

Usa la variante dark: en los componentes:

<div class="bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100">
  Contenido
</div>

Modo oscuro con imágenes

:root {
  --logo-url: url("/images/logo-light.png");
}

[data-theme="dark"] {
  --logo-url: url("/images/logo-dark.png");
}

.logo {
  background-image: var(--logo-url);
  background-size: contain;
  background-repeat: no-repeat;
}

Modo oscuro con resaltado de sintaxis de código

:root {
  --code-bg: #f8f8f8;
  --code-text: #333;
  --code-keyword: #c678dd;
  --code-string: #98c379;
  --code-comment: #7c7c7c;
}

[data-theme="dark"] {
  --code-bg: #282c34;
  --code-text: #abb2bf;
  --code-keyword: #c678dd;
  --code-string: #98c379;
  --code-comment: #5c6370;
}

pre {
  background: var(--code-bg);
  color: var(--code-text);
}

.token.keyword { color: var(--code-keyword); }
.token.string { color: var(--code-string); }
.token.comment { color: var(--code-comment); }

Cómo Funciona

El mecanismo es la cascada de CSS más la re-resolución de custom properties. Las variables declaradas en :root o [data-theme] se heredan hacia abajo hasta cada elemento, así que cuando cambias data-theme en <html>, el navegador re-resuelve cada referencia var(--color-*) y repinta — sin necesidad de código por componente. Compara eso con el enfoque de .dark .card { ... }, donde cada regla de color se duplica bajo el selector oscuro — con tokens, toda la página se reestiliza desde un solo cambio de atributo arriba.

La precedencia importa. La versión con media query solo responde al SO; un toggle del usuario no puede sobreescribirla porque los estilos de @media (prefers-color-scheme: dark) se aplican independientemente de lo que diga localStorage. Por eso la variante con toggle saca los valores oscuros de la media query y los pone detrás de [data-theme="dark"] — el atributo se convierte en la única fuente de verdad, y JavaScript decide si viene de la preferencia guardada o del SO. Si quieres ambos mundos, fija el atributo en la primera carga a partir de prefers-color-scheme (el script anti-FOUC hace exactamente eso) y deja que el toggle tome el control después.

El script inline del <head> existe por una cuestión de sincronización. La construcción del CSSOM ocurre antes de que se parsee el body; un script que corre después de la hoja de estilos ya pintó el tema equivocado y lo mostrará durante un fotograma. Correr inline antes del <link> garantiza que el atributo esté puesto en el primer pintado — son unos 200 bytes de seguro.

Para un toggle más vistoso, document.startViewTransition() permite al navegador capturar ambos estados y animar entre ellos — el mismo efecto de “círculo que se expande desde el botón” de las apps nativas. Envuelve el cambio de data-theme en el callback; la degradación elegante es gratis porque los navegadores sin soporte simplemente cambian al instante.

Si aún no tienes organizados tus tokens, la receta Design Tokens con CSS Custom Properties cubre el nombrado y las capas.

Variantes

Toggle de tres vías (claro/oscuro/sistema)

const THEME_KEY = "theme";

function getStoredTheme() {
  return localStorage.getItem(THEME_KEY) || "system";
}

function getResolvedTheme() {
  const stored = getStoredTheme();
  if (stored === "system") {
    return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
  }
  return stored;
}

function setTheme(theme) {
  localStorage.setItem(THEME_KEY, theme);
  document.documentElement.setAttribute("data-theme", getResolvedTheme());
}

setTheme(getStoredTheme());

window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => {
  if (getStoredTheme() === "system") {
    document.documentElement.setAttribute("data-theme", getResolvedTheme());
  }
});

Usar la propiedad CSS color-scheme

:root {
  color-scheme: light;
}

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

Con esa línea, las barras de desplazamiento y los controles de formulario también se visten acordes al tema.

Chrome del navegador con theme-color

La UI del navegador alrededor de tu página — la barra de URL del móvil, el tinte de la barra de título en algunas plataformas — también puede seguir el tema:

<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#0f172a" media="(prefers-color-scheme: dark)">

Una salvedad: theme-color escucha la media query, no data-theme. Si el usuario elige oscuro manualmente mientras su SO está en claro, la página se oscurece pero el chrome queda claro. Puedes actualizar la etiqueta desde JavaScript cuando corre el toggle — document.querySelector('meta[name="theme-color"]').content = theme === "dark" ? "#0f172a" : "#ffffff".

Accesibilidad y Pruebas

El modo oscuro introduce un nuevo eje de bugs de contraste. Un gris que cumple sobre blanco (--color-text-muted: #6b7280 → 4,8:1) puede fallar sobre fondos oscuros (#94a3b8 sobre #0f172a → 7,3:1, bien — pero revisa cada par). WCAG 2.2 exige 4,5:1 para texto de cuerpo y 3:1 para texto grande y componentes de UI; pasa un verificador de contraste por ambas paletas, no solo la clara.

Dos media queries relacionadas que vale conocer: prefers-contrast: more permite subir el contraste para usuarios que lo piden, y prefers-reduced-motion debería gobernar cualquier animación de transición de tema:

@media (prefers-reduced-motion: no-preference) {
  html.theme-transition, html.theme-transition * {
    transition: background-color 0.2s ease, color 0.2s ease !important;
  }
}

Añade la clase theme-transition brevemente alrededor del clic del toggle y quítala después — transicionar todos los elementos todo el tiempo provoca una tormenta de repintados.

Probar ambos temas a mano cansa rápido. Chrome DevTools lo cubre (pestaña Rendering → “Emulate CSS media feature prefers-color-scheme”), y Playwright puede emularlo por test:

test('dark theme applies', async ({ page }) => {
  await page.emulateMedia({ colorScheme: 'dark' });
  await page.goto('/');
  const bg = await page.evaluate(
    () => getComputedStyle(document.body).backgroundColor
  );
  expect(bg).toBe('rgb(15, 23, 42)'); // #0f172a
});

Afirma sobre el color resuelto, no sobre el atributo — así cubres tanto la vía del media query como las definiciones de los tokens. Para la lista de comprobación de accesibilidad más amplia, mira la Guía Completa de WCAG.

Buenas Prácticas

  • Usa nombres semánticos de tokens (--color-bg, --color-text) — cambiar de tema significa sobrescribir unas pocas variables, no reescribir todo el CSS
  • Coloca el script anti-FOUC inline en <head> antes del CSS — debe ejecutarse antes del primer pintado
  • Respeta la preferencia del sistema por defecto — solo sobreescribe cuando el usuario alterna explícitamente
  • Escucha los cambios de prefers-color-scheme — si el usuario no fijó una preferencia manual, sigue al SO
  • Prueba ambos temas — algunas combinaciones de color se ven bien en modo claro pero tienen contraste pobre en modo oscuro
  • Usa color-scheme: light dark — esto estiliza correctamente los elementos nativos de UI (barras de desplazamiento, controles de formulario)
  • Reduce la intensidad de las sombras en modo oscuro — las sombras son menos visibles en fondos oscuros y pueden verse agresivas

Errores Comunes

  • Destello de tema claro (FOUC): no fijar data-theme antes de que cargue el CSS. El script inline en el head lo previene.
  • Colores escritos directamente en componentes: background: #fff ignora por completo la capa de tokens — esa tarjeta queda blanca mientras el resto de la página se oscurece.
  • No persistir la elección del usuario: sin localStorage, el toggle del usuario se reinicia en cada recarga de página.
  • Ignorar la preferencia del sistema: ir a claro por defecto cuando el SO del usuario está en oscuro — consulta prefers-color-scheme primero.
  • Contraste pobre en modo oscuro: gris oscuro sobre fondo oscuro desaparece. Pasa ambas paletas por un verificador de contraste.
  • Transicionar en la carga: pon un * { transition: all } global y la aplicación inicial del tema también se anima — el sitio se desvanece como una presentación en cada visita. Acota las transiciones al momento del toggle.
  • Leer localStorage en cada comprobación: el acceso a localStorage puede lanzar errores en casos límite de navegación privada y bloquea el hilo principal — envuélvelo en try/catch si soportas modos de privacidad estrictos.
  • Olvidar color-scheme: sin ella, las barras de desplazamiento y los controles de formulario quedan claros dentro de tu UI oscura — una propiedad lo arregla.
  • Discrepancias de SSR: los frameworks que renderizan en el servidor no pueden ver localStorage — el script inline del head debe ejecutarse en el cliente antes de la hidratación o React/Vue avisará de discrepancias de atributos.

¿Sigues viendo el destello? Depura en este orden: confirma que el script inline es realmente lo primero en <head> (un <link> bloqueante o @import por encima repinta antes de que se ejecute); comprueba que data-theme se fija — localStorage lanza errores en algunos modos privados, así que envuelve la lectura; y verifica que no estás mezclando vías — el CSS detrás de @media (prefers-color-scheme: dark) ignora el atributo, así que elige la variante de media query o la de data-theme, no ambas para las mismas propiedades.

Lecturas Adicionales

Preguntas frecuentes

¿Qué es prefers-color-scheme?

Es el media query que lee el tema de tu sistema operativo — prefers-color-scheme: dark es verdadero siempre que el SO esté en modo oscuro, y el navegador se lo expone a cada página.

¿Cómo evito el destello del tema incorrecto?

Pon un script inline minúsculo en <head> que estampe data-theme antes de que cargue la hoja de estilos — el tema correcto aparece ya en el primer pintado.

¿Debería usar prefers-color-scheme o un toggle manual?

Ambos. Usa prefers-color-scheme como valor por defecto y luego deja que el usuario lo sobreescriba con un toggle manual. Guarda su elección en localStorage y respétala en visitas posteriores.

¿prefers-color-scheme funciona en todos los navegadores?

Chrome lo soporta desde la 76, Firefox desde la 67, Safari desde la 12.1, Edge desde la 79 — eso es el 96%+ de usuarios en 2026. Los navegadores más antiguos simplemente ven el modo claro.

¿Cómo manejo las imágenes en modo oscuro?

Usa CSS custom properties para las URLs de imágenes, o aplica filter: invert(1) para iconos simples. Para fotos, considera reducir ligeramente el brillo en modo oscuro:

[data-theme="dark"] img {
  filter: brightness(0.9);
}
¿Puedo animar el cambio de tema?

Sí, pero acótalo — aplica una clase de transición solo alrededor del clic del toggle y quítala después, y respeta prefers-reduced-motion. Un transition: all global también anima la carga inicial de la página, lo que produce un repintado a cámara lenta en cada visita.