Dark Mode con prefers-color-scheme y CSS Variables
Cómo implementar dark mode usando el media query CSS prefers-color-scheme, CSS custom properties y toggle manual con persistencia en localStorage.
Nota para desarrolladores hispanohablantes: Esta guía incluye ejemplos y convenciones de nomenclatura adaptadas a equipos que trabajan en español. Cuando existen diferencias significativas en terminología técnica entre el inglés y el español, se indican explícitamente para facilitar la comunicación en equipos multiculturales.
Overview
El media query CSS prefers-color-scheme detecta si el usuario prefiere light o dark mode a nivel de OS. Combinado con CSS custom properties, puedes switchear todos los colores, bordes y sombras overrideando unas pocas variables. Un toggle manual con localStorage deja a los usuarios overridear la preferencia del sistema, y un pequeño script inline previene el flash de tema incorrecto en page load.
When to Use
- Cualquier sitio web que debería respetar el setting de dark mode del OS del usuario
- Aplicaciones donde los usuarios pasan tiempo extendido y la fatiga visual importa
- Sitios con code blocks — dark mode reduce el glare para developers
- Sitios content-heavy donde dark mode mejora la legibilidad en ambientes de poca luz
When NOT to Use
- Esquemas de color mandatorios por brand que no deben cambiar
- Print stylesheets — dark mode no aplica a páginas impresas
- Browsers legacy sin soporte de
prefers-color-scheme(IE11, Safari antiguo)
Solution
Dark mode 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 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 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");
}
Prevenir flash 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 visibilidad de 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 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");
}
});
Dark mode 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 componentes:
<div class="bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100">
Content
</div>
Dark mode 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;
}
Dark mode con syntax highlighting 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); }
Variants
Toggle de tres vías (light/dark/system)
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;
}
Esto le dice al browser que renderice los elementos de UI nativos (scrollbars, form controls) en el color scheme correspondiente.
Best Practices
-
For a deeper guide, see Design Tokens with CSS Custom Properties.
-
Usa nombres semánticos de tokens (
--color-bg,--color-text) — switchear temas significa overridear unas pocas variables, no reescribir todo el CSS -
Coloca el script anti-FOUC inline en
<head>antes del CSS — debe correr antes del primer paint -
Respeta la preferencia del sistema por defecto — solo overridea cuando el usuario togglea explícitamente
-
Escucha cambios de
prefers-color-scheme— si el usuario no seteó una preferencia manual, sigue el OS -
Testea ambos temas — algunas combinaciones de color se ven bien en light mode pero tienen contraste pobre en dark mode
-
Usa
color-scheme: light dark— esto estila los elementos de UI nativos (scrollbars, form controls) correctamente -
Reduce la intensidad de sombras en dark mode — las sombras son menos visibles en fondos oscuros y pueden verse harsh
Common Mistakes
- Flash de tema light (FOUC): no setear
data-themeantes de que cargue el CSS. El script inline en head lo previene. - Colores hardcodeados en componentes:
background: #fffen lugar devar(--color-bg)— el componente no switchea en dark mode. - No persistir la elección del usuario: sin
localStorage, el toggle del usuario se resetea en cada page reload. - Ignorar la preferencia del sistema: defaultear a light cuando el OS del usuario está en dark — checkea
prefers-color-schemeprimero. - Contraste pobre en dark mode: texto gris oscuro en fondos oscuros es ilegible. Verifica contrast ratios con una herramienta.
FAQ
¿Qué es prefers-color-scheme?
Un media query CSS que matchea la preferencia de color scheme del usuario a nivel OS. prefers-color-scheme: dark matchea cuando el usuario tiene dark mode habilitado en los settings del OS.
¿Cómo prevengo el flash de tema incorrecto?
Setea el atributo data-theme con un script inline en <head> antes de que cargue el CSS. Esto asegura que el tema correcto se aplique en el primer paint.
¿Debería usar prefers-color-scheme o un toggle manual?
Ambos. Usa prefers-color-scheme como default, luego deja que el usuario overridee con un toggle manual. Guarda su elección en localStorage y respétala en visitas subsecuentes.
¿prefers-color-scheme funciona en todos los browsers?
Soportado en Chrome 76+, Firefox 67+, Safari 12.1+ y Edge 79+. Esto cubre 96%+ de usuarios a 2026. Para browsers más antiguos, light mode es el default.
¿Cómo manejo imágenes en dark mode?
Usa CSS custom properties para URLs de imágenes, o aplica filter: invert(1) para iconos simples. Para fotos, considera reducir brightness ligeramente en dark mode:
[data-theme="dark"] img {
filter: brightness(0.9);
} Recursos Relacionados
Design Tokens with CSS Custom Properties
How to build a design token system using CSS custom properties, including color scales, spacing, typography, themes, and responsive scaling.
RecipeContainer Queries for Component Responsiveness
How to use CSS container queries for component-level responsive layouts that adapt to their container size instead of the viewport.
RecipeWhen to Use useMemo and useCallback
How and when to use React's useMemo and useCallback hooks for performance optimization, and when they add unnecessary overhead.
GuideCSS Modern Layout: Grid, Flexbox, Container Queries
Master modern CSS layout: CSS Grid, Flexbox, container queries, subgrid, logical properties, and responsive design patterns without media queries.