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.
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-themeantes de que cargue el CSS. El script inline en el head lo previene. - Colores escritos directamente en componentes:
background: #fffignora 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-schemeprimero. - 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
localStorageen cada comprobación: el acceso alocalStoragepuede 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
- Design Tokens con CSS Custom Properties — organiza la capa de tokens que el modo oscuro intercambia.
- Guía Completa de WCAG — requisitos de contraste y el media query relacionado
prefers-contrast. - MDN: prefers-color-scheme — referencia del media query y soporte de navegadores.
- caniuse: prefers-color-scheme — datos de cobertura actualizados.
- Demo companion —
index.html+styles.css+theme.jsejecutables que puedes usar tal cual en un proyecto.
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.
Recursos Relacionados
Design Tokens con CSS Custom Properties
Cómo construir un sistema de design tokens usando CSS custom properties, incluyendo escalas de color, spacing, tipografía, temas y escalado responsivo.
RecipeContainer Queries para Responsiveness de Componentes
Cómo usar CSS container queries para layouts responsivos a nivel de componente que se adaptan al tamaño de su contenedor en lugar del viewport.
PatternBridge Pattern para Desacoplar Componentes UI de Temas
Separa una abstraccion de su implementacion para que ambas puedan variar independientemente usando el Bridge pattern para temas UI y motores de renderizado intercambiables
GuideLayout CSS Moderno: Grid, Flexbox, container queries
Dominá modern CSS layout: CSS Grid, Flexbox, container queries, subgrid, logical properties y responsive design patterns sin media queries.
GuideAccesibilidad Web: WCAG 2.2
Dominá accesibilidad web con WCAG 2.2: roles ARIA, navegación por teclado, soporte para screen readers, contraste de color y formularios accesibles.