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.
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.
Preguntas frecuentes
¿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 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.
RecipeCuándo Usar useMemo y useCallback
Cómo y cuándo usar los hooks useMemo y useCallback de React para optimización de performance, y cuándo añaden overhead innecesario.
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.