El modo oscuro bien hecho no es "poner fondo negro": es rediseñar la jerarquía de superficies para que la jerarquía visual sobreviva al cambio. La herramienta que lo hace mantenible son las variables CSS usadas como sistema de tokens — un solo set de valores, dos temas, cero duplicación.
El patrón base: tokens semánticos
El error de principiante es condicionar cada color directamente:
/* FRÁGIL: cada componente repite la condición */
.card { background: white; }
@media (prefers-color-scheme: dark) { .card { background: #1a1a1a; } }
El patrón correcto define tokens semánticos — nombres que describen función, no valor:
:root {
--bg-surface: #ffffff;
--bg-page: #f5f5f7;
--text-primary: #111418;
--text-muted: #5c6370;
--border-subtle: rgba(0, 0, 0, 0.08);
--accent: #7c3aed;
}
[data-theme="dark"] {
--bg-surface: #1c1e26;
--bg-page: #101116;
--text-primary: #e8eaed;
--text-muted: #9aa0ab;
--border-subtle: rgba(255, 255, 255, 0.09);
--accent: #9d71ff;
}
Y los componentes consumen tokens, jamás colores literales:
.card {
background: var(--bg-surface);
color: var(--text-primary);
border: 1px solid var(--border-subtle);
}
Añadir un tema nuevo (high contrast, sepia, brand seasonal) pasa a ser definir otro bloque de tokens. Los componentes ni se enteran.
Seguir el sistema Y permitir elegir
Los usuarios esperan tres opciones: claro, oscuro y automático. El patrón estándar combina prefers-color-scheme con una preferencia manual guardada:
const stored = localStorage.getItem("theme"); // "light" | "dark" | null
const system = matchMedia("(prefers-color-scheme: dark)").matches;
const theme = stored ?? (system ? "dark" : "light");
document.documentElement.dataset.theme = theme;
// Toggle manual
document.querySelector("#toggle").onclick = () => {
const next = document.documentElement.dataset.theme === "dark" ? "light" : "dark";
document.documentElement.dataset.theme = next;
localStorage.setItem("theme", next); // null = volver a seguir el sistema
};
Para que el tema del SISTEMA también funcione cuando el usuario no ha elegido nada:
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) { /* tokens oscuros */ }
}
O más simple: resolver todo en JS al cargar (el snippet anterior) y solo mantener [data-theme="dark"] en CSS — menos CSS, misma UX, a costa de requerir JS para el modo auto.
El flash blanco: FOUC de temas
Sin cuidado, quien navega en oscuro ve un flash blanco antes de que cargue tu JS. La solución clásica: un script inline mínimo en <head>, ANTES de cualquier render:
<script>
(function(){
var t = localStorage.getItem("theme");
if (!t) t = matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
document.documentElement.dataset.theme = t;
})();
</script>
Cinco líneas síncronas que eliminan el flash por completo. Next.js lo encapsula en next-themes, que además gestiona la hidratación sin mismatch.
Diseñar oscuro ≠ invertir colores
Reglas específicas del tema oscuro que separan resultado profesional de invertido automático:
- Nunca negro puro (#000): genera halos en OLED y contrastes brutales. Superficies oscuras desaturadas (#101116 → #1c1e26) crean profundidad.
- Invierte la jerarquía de elevación: en claro, "más elevado" = más blanco; en oscuro, "más elevado" = más claro que el fondo.
- Reduce intensidad, no saturación: colores vivos vibran sobre fondo oscuro. Baja luminosidad o sube lightness del accent (violeta #7c3aed → #9d71ff).
- Sombras casi invisibles: sobre fondo oscuro apenas se ven; sustitúyelas parcialmente por bordes sutiles (
border-subtle). - Imágenes y logos: revisa assets negros-sobre-transparente; muchos sitios sirven variantes SVG por tema.
Si diseñas las paletas desde cero, construye primero los tokens claros y deriva los oscuros ajustando lightness — nuestro generador de paletas te da bases armónicas para ambos mundos, y la guía de espacios de color explica por qué OKLCH facilita esas derivaciones perceptuales.
Transición suave entre temas
Un transition global en colores evita el cambio seco:
html.theme-transition,
html.theme-transition *,
html.theme-transition *::before,
html.theme-transition *::after {
transition: background-color .3s ease, border-color .3s ease, color .2s ease !important;
}
Actívalo con clase solo durante el toggle (añadir-clase → cambiar tema → quitar tras 300ms): aplicarlo permanente penaliza rendimiento en scroll por las reglas de animación.
Preguntas frecuentes
¿Debo respetar prefers-color-scheme si tengo marca blanca? Recomendado: ofrece auto por defecto y deja elegir. Forzar claro pese al sistema es la queja número uno en apps grandes.
¿Las variables funcionan en todos lados? Soporte universal desde 2017 (IE11 fuera). Para legacy extremo, compila fallbacks estáticos con PostCSS.
¿Cómo pruebo ambos temas rápido? DevTools → Rendering → Emulate CSS media feature prefers-color-scheme. Combinado con tu toggle manual cubre los tres estados.
Construye paletas coherentes para ambos temas con nuestro generador de paletas, gratis y directamente en tu navegador.