Modo oscuro
Modo oscuro, esquemas de color y tematización dinámica con Tailwind CSS v4.
Busca en todas las páginas de la documentación
Modo oscuro, esquemas de color y tematización dinámica con Tailwind CSS v4.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
Tarjeta de referencia rápida - lista para copiar y pegar.
// Utilidades de modo oscuro - solo añade el prefijo dark:
<div className="bg-white text-gray-900 dark:bg-gray-950 dark:text-gray-100">
<p className="text-gray-600 dark:text-gray-400">Se adapta a la preferencia del sistema</p>
<button className="bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-400">
Haz clic en mí
</button>
</div>/* globals.css - Tailwind v4 usa prefers-color-scheme por defecto */
@import "tailwindcss";
/* Para modo oscuro basado en clase (alternado con JS): */
@variant dark (&:where(.dark, .dark *));
/* Tokens semánticos que cambian automáticamente */
@theme {
--color-surface: #ffffff;
--color-on-surface: #111827;
}
@layer base {
.dark {
--color-surface: #0f172a;
--color-on-surface: #f1f5f9;
}
}Cuándo usarlo: Toda aplicación orientada al usuario debería admitir modo oscuro: reduce la fatiga visual y respeta las preferencias del usuario.
"use client";
import { useEffect, useState } from "react";
type Theme = "light" | "dark" | "system";
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>("system");
useEffect(() => {
const stored = localStorage.getItem("theme") as Theme | null;
if (stored) setTheme(stored);
}, []);
useEffect(() => {
const root = document.documentElement;
if (theme === "system") {
root.classList.remove("dark");
const mq = window.matchMedia("(prefers-color-scheme: dark)");
if (mq.matches) root.classList.add("dark");
const handler = (e: MediaQueryListEvent) => {
root.classList.toggle("dark", e.matches);
};
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}
root.classList.toggle("dark", theme === "dark");
localStorage.setItem("theme", theme);
}, [theme]);
return (
<div className="min-h-screen bg-surface text-on-surface transition-colors duration-300">
<header className="flex items-center justify-between border-b border-gray-200 p-4 dark:border-gray-800">
<h1 className="text-lg font-bold">Mi app</h1>
<ThemeToggle theme={theme} setTheme={setTheme} />
</header>
<main className="mx-auto max-w-4xl p-6">{children}</main>
</div>
);
}
function ThemeToggle({
theme,
setTheme,
}: {
theme: Theme;
setTheme: (t: Theme) => void;
}) {
const options: { value: Theme; label: string }[] = [
{ value: "light", label: "Claro" },
{ value: "dark", label: "Oscuro" },
{ value: "system", label: "Sistema" },
];
return (
<div className="flex gap-1 rounded-lg bg-gray-100 p-1 dark:bg-gray-800">
{options.map((opt) => (
<button
key={opt.value}
onClick={() => setTheme(opt.value)}
className={`rounded-md px-3 py-1 text-sm font-medium transition-colors ${
theme === opt.value
? "bg-white text-gray-900 shadow-sm dark:bg-gray-700 dark:text-white"
: "text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white"
}`}
>
{opt.label}
</button>
))}
</div>
);
}/* globals.css para el ejemplo anterior */
@import "tailwindcss";
@variant dark (&:where(.dark, .dark *));
@theme {
--color-surface: #ffffff;
--color-on-surface: #111827;
--color-surface-muted: #f9fafb;
--color-primary: #2563eb;
--color-primary-foreground: #ffffff;
}
@layer base {
.dark {
--color-surface: #0f172a;
--color-on-surface: #f1f5f9;
--color-surface-muted: #1e293b;
--color-primary: #3b82f6;
--color-primary-foreground: #ffffff;
}
}Lo que esto demuestra:
localStoragematchMediatransition-colorsdark: de Tailwind v4 usa @media (prefers-color-scheme: dark) - no hace falta configuración@variant dark (&:where(.dark, .dark *)) en tu CSS:where() mantiene la especificidad en cero, así que dark:bg-gray-900 no supera a bg-white por especificidadnext-themes es la biblioteca estándar para modo oscuro en Next.js: gestiona SSR, prevención de flash y sincronización con el sistemaUsando next-themes:
// app/layout.tsx
import { ThemeProvider } from "next-themes";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<body>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
{children}
</ThemeProvider>
</body>
</html>
);
}
// Uso en componentes
"use client";
import { useTheme } from "next-themes";
function Toggle() {
const { theme, setTheme } = useTheme();
return (
<button onClick={() => setTheme(theme === "dark" ? "light" : "dark")}>
Alternar
</button>
);
}Múltiples temas (más allá de claro/oscuro):
@layer base {
[data-theme="ocean"] {
--color-primary: #0891b2;
--color-surface: #f0fdfa;
}
[data-theme="sunset"] {
--color-primary: #ea580c;
--color-surface: #fff7ed;
}
}Imágenes conscientes del modo oscuro:
<picture>
<source srcSet="/logo-dark.svg" media="(prefers-color-scheme: dark)" />
<img src="/logo-light.svg" alt="Logo" />
</picture>
{/* O con Tailwind: */}
<img src="/logo-light.svg" className="dark:hidden" alt="Logo" />
<img src="/logo-dark.svg" className="hidden dark:block" alt="Logo" />// Tipo Theme
type Theme = "light" | "dark" | "system";
// Tipos de next-themes
import type { ThemeProviderProps } from "next-themes";
// Colores de tema con tipado seguro
const themeColors = {
light: { bg: "bg-white", text: "text-gray-900" },
dark: { bg: "bg-gray-950", text: "text-gray-100" },
} as const satisfies Record<string, { bg: string; text: string }>;Flash del tema incorrecto (FOIT) - Sin gestión de SSR, la página muestra brevemente el tema equivocado. Solución: Usa next-themes con suppressHydrationWarning en <html>, o añade un <script> bloqueante en <head> que establezca la clase.
prefers-color-scheme frente a alternancia por clase - El comportamiento por defecto de v4 usa la media query. Si añades alternancia con la clase .dark, debes sobrescribir la variante. Solución: Añade @variant dark (&:where(.dark, .dark *)).
Modificadores de opacidad en modo oscuro - bg-blue-500/50 funciona, pero dark:bg-blue-500/50 puede no componerse bien con tokens semánticos. Solución: Define valores de opacidad separados en tus tokens de tema.
Contraste de color - El modo oscuro no consiste en «invertir todo». El texto claro sobre fondos oscuros necesita ratios de contraste distintos. Solución: Prueba con verificadores de contraste WCAG.
Imágenes y sombras - Las imágenes brillantes y las sombras oscuras se ven mal en modo oscuro. Solución: Reduce la intensidad de las sombras (dark:shadow-none), añade superposiciones en las imágenes o usa el filtro brightness.
| Alternativa | Usar cuando | No usar cuando |
|---|---|---|
next-themes | App Next.js con SSR (evita el flash) | Proyectos que no usan Next.js |
CSS color-scheme: dark | Quieres que la UI del navegador (scrollbars, inputs) se adapte | Necesitas tematización personalizada de componentes |
Solo prefers-color-scheme | Quieres modo oscuro automático según el sistema | Los usuarios necesitan control manual de alternancia |
Tailwind darkMode: "selector" (v3) | Estás en Tailwind v3 | Estás en v4 |
Por defecto, dark: usa @media (prefers-color-scheme: dark) - sigue la preferencia del sistema operativo del usuario sin necesidad de configuración.
Añade esto a tu CSS:
@variant dark (&:where(.dark, .dark *));Luego alterna la clase .dark en el elemento <html> mediante JavaScript.
--color-surface y --color-on-surface:root, sobrescritos en .darkbg-surface en lugar de bg-white dark:bg-gray-950next-themes. Gestiona SSR, evita el flash del tema incorrecto, admite la preferencia del sistema y funciona con la estrategia del atributo class.
next-themes añade el atributo class en el cliente antes de la hidratación. Sin suppressHydrationWarning, React advierte sobre el desajuste servidor/cliente.
@layer base {
[data-theme="ocean"] {
--color-primary: #0891b2;
}
[data-theme="sunset"] {
--color-primary: #ea580c;
}
}Usa un atributo data-theme en lugar de la clase .dark.
<img src="/logo-light.svg" className="dark:hidden" alt="Logo" />
<img src="/logo-dark.svg" className="hidden dark:block" alt="Logo" />El servidor renderiza sin conocer el tema del usuario. El cliente aplica después el tema correcto, provocando un flash visible. Solución: usa next-themes o añade un <script> bloqueante en <head> que establezca la clase antes del pintado.
La luminosidad oklch no es simétrica. Cambiar solo el número de tono no garantiza buen contraste en modo oscuro. Prueba los colores visualmente en lugar de invertir números.
type Theme = "light" | "dark" | "system";Para next-themes, importa ThemeProviderProps desde next-themes para soporte de tipos completo.
const themeColors = {
light: { bg: "bg-white", text: "text-gray-900" },
dark: { bg: "bg-gray-950", text: "text-gray-100" },
} as const satisfies Record<string, { bg: string; text: string }>;Revisado por Chris St. John·Última actualización: 16 jul 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥