El modo oscuro ya no es un extra opcional, es algo que se da por sentado. Los usuarios alternan entre claro y oscuro según la luz del entorno, sus preferencias personales y la fatiga visual. Una landing page que ignora el modo oscuro se ve rota en las pantallas de la mitad de tus visitantes. Esta guía recorre los patrones y los detalles de implementación para construir componentes React que funcionen sin fallos en ambos temas.
El enfoque con propiedades personalizadas CSS
La base de cualquier sistema de temas son las propiedades personalizadas CSS (variables). Defines tus tokens de color una sola vez y luego intercambias sus valores según el tema activo:
/* globals.css */
:root {
--color-background: #ffffff;
--color-foreground: #0a0a0a;
--color-card: #ffffff;
--color-border: #e5e5e5;
--color-muted: #737373;
--color-primary: #2563eb;
--color-primary-foreground: #ffffff;
}
[data-theme="dark"] {
--color-background: #0a0a0a;
--color-foreground: #fafafa;
--color-card: #171717;
--color-border: #262626;
--color-muted: #a3a3a3;
--color-primary: #3b82f6;
--color-primary-foreground: #ffffff;
}
Los componentes hacen referencia a estos tokens, nunca a valores hexadecimales escritos a mano:
<section
style={{
background: "var(--color-background)",
color: "var(--color-foreground)",
}}
>
<div style={{ borderColor: "var(--color-border)" }}>
{/* content */}
</div>
</section>
Cuando el atributo data-theme cambia en el elemento raíz, todos los componentes se actualizan a la vez. Sin prop drilling, sin re-renderizados por contexto, pura cascada CSS.
Modo oscuro con Tailwind CSS
Tailwind ofrece el prefijo de variante dark:. En Tailwind v4, el modo oscuro usa por defecto la media query prefers-color-scheme. Para activar el cambio basado en clases (lo recomendable cuando el usuario controla el tema), configura el selector en tu CSS:
/* app.css, Tailwind v4 */
@import "tailwindcss";
@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
Ahora puedes usar utilidades dark: en cualquier sitio:
export function FeatureCard({ title, description }: { title: string; description: string }) {
return (
<div className="rounded-2xl border border-neutral-200 bg-white p-6 dark:border-neutral-800 dark:bg-neutral-900">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
{title}
</h3>
<p className="mt-2 text-sm text-neutral-600 dark:text-neutral-400">
{description}
</p>
</div>
);
}
El prefijo dark: queda limpio y junto al marcado del componente. Sin hojas de estilo aparte, sin el coste en tiempo de ejecución del CSS-in-JS.
Interruptor de tema con transición suave
Un interruptor de tema necesita (1) actualizar el atributo del DOM, (2) guardar la preferencia y (3) evitar un parpadeo con el tema equivocado al cargar la página. Aquí tienes una implementación completa:
"use client";
import { useEffect, useState } from "react";
import { motion } from "motion/react";
type Theme = "light" | "dark";
function getStoredTheme(): Theme {
if (typeof window === "undefined") return "light";
return (localStorage.getItem("theme") as Theme) ?? "light";
}
export function ThemeToggle() {
const [theme, setTheme] = useState<Theme>("light");
useEffect(() => {
const stored = getStoredTheme();
setTheme(stored);
document.documentElement.setAttribute("data-theme", stored);
}, []);
function toggle() {
const next = theme === "light" ? "dark" : "light";
setTheme(next);
document.documentElement.setAttribute("data-theme", next);
localStorage.setItem("theme", next);
}
return (
<button
onClick={toggle}
className="relative h-8 w-14 rounded-full bg-neutral-200 dark:bg-neutral-700"
aria-label="Toggle theme"
>
<motion.div
className="absolute top-1 left-1 h-6 w-6 rounded-full bg-white shadow-sm"
animate={{ x: theme === "dark" ? 24 : 0 }}
transition={{ type: "spring", stiffness: 500, damping: 30 }}
/>
</button>
);
}
Para evitar el parpadeo con el tema equivocado, añade un script inline bloqueante en el <head> del layout raíz. Este script lee el tema guardado en localStorage y fija el atributo data-theme de forma síncrona antes de que React hidrate:
// app/layout.tsx, inside <head>
<script>{`
(function() {
var theme = localStorage.getItem('theme') || 'light';
document.documentElement.setAttribute('data-theme', theme);
})();
`}</script>
Esto se ejecuta antes del primer pintado, así que la página se renderiza con el tema correcto desde el principio.
Diseño dark-first
La mayoría de los desarrolladores diseñan primero en modo claro y luego encajan el modo oscuro como algo secundario. ¿El resultado? Un modo oscuro lavado, con poco contraste o con elementos olvidados que conservan fondos blancos.
Hay un enfoque mejor: diseñar primero el modo oscuro. Las interfaces oscuras dejan al descubierto los problemas de contraste de inmediato. Si un componente se ve bien sobre un fondo oscuro, casi con seguridad funcionará en modo claro con ajustes mínimos. Lo contrario no se cumple.
Reglas prácticas para el diseño dark-first:
- Bordes en lugar de sombras. Las sombras son casi invisibles sobre fondos oscuros. Usa bordes (
border-neutral-800) para definir los límites de las tarjetas en modo oscuro y, si quieres, añade sombras solo para el modo claro. - Fondos atenuados para dar profundidad. En vez de sombras, recurre a tonos de fondo ligeramente más claros para crear elevación:
bg-neutral-900para la página,bg-neutral-800para las tarjetas,bg-neutral-700para los elementos elevados. - Evita el negro puro. Los fondos
#000000generan un contraste excesivo con el texto blanco y provocan fatiga visual. Usa#0a0a0ao#111111en su lugar. - Prueba con un contraste mínimo de 3:1. WCAG AA exige 4.5:1 para el texto corrido y 3:1 para el texto grande. Las paletas de modo oscuro suelen fallar aquí, así que compruébalo con el panel de accesibilidad de las DevTools del navegador.
Ratios de contraste
El error más habitual del modo oscuro es un contraste insuficiente. Aquí tienes una referencia rápida para texto neutro sobre fondos oscuros:
| Background | Text Color | Contrast Ratio | WCAG AA |
|-----------|-----------|---------------|---------|
| #0a0a0a | #fafafa | 19.3:1 | Pass |
| #0a0a0a | #a3a3a3 | 7.2:1 | Pass |
| #0a0a0a | #737373 | 4.2:1 | Pass (large text) |
| #0a0a0a | #525252 | 2.6:1 | Fail |
| #171717 | #a3a3a3 | 6.3:1 | Pass |
| #171717 | #737373 | 3.7:1 | Pass (large text) |
Usa #a3a3a3 o más claro para el texto corrido sobre fondos oscuros. Reserva #737373 para etiquetas, pies de foto y otro texto secundario, y solo en tamaños grandes.
Patrones de componentes
Al construir componentes de sección listos para modo oscuro, sigue estos patrones:
- Usa nombres de tokens semánticos (
--color-foreground) y no colores en crudo (#0a0a0a). Así el tema se aplica de forma automática. - Nunca escribas
bg-whitea mano, usa los tokensbg-cardobg-background, que se intercambian en modo oscuro. - Las imágenes e ilustraciones necesitan variantes oscuras o fondos transparentes. Un PNG con fondo blanco sobre una página oscura se ve roto.
- Los bloques de código deberían usar un tema de sintaxis que funcione en ambos fondos, o cambiar de tema junto con el interruptor.
Secciones en modo oscuro listas para usar
El catálogo de Incubator incluye más de 844 secciones React construidas con un sistema de tokens CSS que admite modo claro y oscuro desde el primer momento. Cada sección, desde bloques hero hasta tablas de precios y cuadrículas de características, usa tokens de color semánticos que responden al instante a los cambios de tema. Explora el catálogo, activa el modo oscuro y copia las secciones que encajen con tu diseño.
Relacionado en incubator
- Secciones React oscuras y minimalistas: explora secciones construidas con enfoque dark-first.
- Componentes hero de React: secciones hero a sangre completa con soporte de temas.
- Componentes navbar de React: secciones de navegación con patrones de interruptor claro/oscuro.
- Incubator frente a Aceternity UI: compara librerías de animación con modo oscuro.
- Componentes de interfaz minimalistas: variantes de sección de bajo contraste basadas en tokens.