El modo oscuro no es una funcionalidad que se añade al final. Toca cada componente, cada color de fondo, cada borde, cada tono de texto, cada campo de entrada. Cuando implementamos el modo oscuro para el panel de control de 0fee.dev en la sesión 078, significó actualizar 19 páginas, 6 componentes compartidos y 7 páginas de autenticación/pago -- un barrido sistemático que añadió cientos de clases de utilidad dark: siguiendo patrones consistentes.
Este artículo cubre el enfoque sistemático, los patrones de modo oscuro de Tailwind que estandarizamos, las más de 44 clases oscuras del componente Select, el tratamiento completo del modo oscuro del modal CreditBalance y las exclusiones intencionales que documentamos y dejamos sin modificar.
La escala del problema
Antes de la sesión 078, el panel de control existía solo en modo claro. El soporte de modo oscuro requirió actualizar:
| Categoría | Cantidad | Rango de cambios |
|---|---|---|
| Páginas del panel | 19 | 39-373 clases dark cada una |
| Componentes UI | 6 de 11 | Varía |
| Páginas de autenticación/pago | 7 | Todas actualizadas |
| Excluidas intencionalmente | 5 componentes | Ya tenían tema oscuro |
Una "clase dark" significa una utilidad de TailwindCSS como dark:bg-gray-900 o dark:text-gray-200. Cada una especifica cómo debe ser una propiedad cuando la preferencia de tema del usuario es oscura.
Configuración del modo oscuro
TailwindCSS soporta el modo oscuro mediante la estrategia class, donde al añadir una clase dark al elemento raíz se activan todas las utilidades con prefijo dark::
javascript// tailwind.config.js
module.exports = {
darkMode: "class",
// ...
};La alternancia de tema persiste en localStorage:
typescriptfunction toggleDarkMode() {
const isDark = document.documentElement.classList.toggle("dark");
localStorage.setItem("theme", isDark ? "dark" : "light");
}
// Al cargar la página
const saved = localStorage.getItem("theme");
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
if (saved === "dark" || (!saved && prefersDark)) {
document.documentElement.classList.add("dark");
}Patrones oscuros estandarizados
Establecimos mapeos consistentes para cada tipo de elemento:
Fondos
| Elemento | Claro | Oscuro |
|---|---|---|
| Fondo de página | bg-gray-100 | dark:bg-gray-950 |
| Fondo de tarjeta | bg-white | dark:bg-gray-900 |
| Fondo de input | bg-white | dark:bg-gray-800 |
| Fondo hover | hover:bg-gray-50 | dark:hover:bg-gray-800 |
| Fila seleccionada | bg-emerald-50 | dark:bg-emerald-900/20 |
| Encabezado de tabla | bg-gray-50 | dark:bg-gray-800/50 |
Colores de texto
| Elemento | Claro | Oscuro |
|---|---|---|
| Texto principal | text-gray-900 | dark:text-gray-100 |
| Texto secundario | text-gray-600 | dark:text-gray-400 |
| Texto atenuado | text-gray-500 | dark:text-gray-500 |
| Texto de enlace | text-emerald-600 | dark:text-emerald-400 |
Bordes
| Elemento | Claro | Oscuro |
|---|---|---|
| Borde de tarjeta | border-gray-300 | dark:border-gray-700 |
| Borde de input | border-gray-300 | dark:border-gray-600 |
| Divisor | border-gray-200 | dark:border-gray-800 |
| Anillo de foco | focus:border-emerald-500 | dark:focus:border-emerald-400 |
Colores de estado
Las insignias de estado mantienen sus colores semánticos en ambos modos, con opacidad ajustada para fondos oscuros:
tsxfunction StatusBadge(props: { status: string }) {
const colors = {
completed: "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-400",
pending: "bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400",
failed: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400",
cancelled: "bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-400",
};
return (
<span class={`px-2 py-1 rounded-full text-xs font-medium ${colors[props.status]}`}>
{props.status}
</span>
);
}El componente Select: más de 44 clases dark
El componente Select personalizado (Select.tsx) fue la actualización de modo oscuro más compleja, requiriendo más de 44 clases dark en 13 elementos diferentes:
tsxfunction Select(props: SelectProps) {
return (
<div class="relative">
{/* Etiqueta */}
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{props.label}
</label>
{/* Botón disparador */}
<button
class="w-full flex items-center justify-between px-3 py-2
bg-white dark:bg-gray-800
border border-gray-300 dark:border-gray-600
rounded-lg text-sm
text-gray-900 dark:text-gray-100
hover:border-gray-400 dark:hover:border-gray-500
focus:ring-2 focus:ring-emerald-500 dark:focus:ring-emerald-400"
>
<span class={props.value
? "text-gray-900 dark:text-gray-100"
: "text-gray-500 dark:text-gray-400"
}>
{props.value || props.placeholder}
</span>
<ChevronDown class="w-4 h-4 text-gray-400 dark:text-gray-500" />
</button>
{/* Desplegable */}
<Show when={isOpen()}>
<div class="absolute z-50 w-full mt-1
bg-white dark:bg-gray-800
border border-gray-200 dark:border-gray-700
rounded-lg shadow-lg dark:shadow-gray-900/50
max-h-60 overflow-y-auto">
<For each={props.options}>
{(option) => (
<button
class={`w-full text-left px-3 py-2 text-sm
${option.value === props.value
? "bg-emerald-50 dark:bg-emerald-900/20 text-emerald-700 dark:text-emerald-400"
: "text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700"
}`}
onClick={() => selectOption(option)}
>
<div class="flex items-center gap-2">
<Show when={option.icon}>
<span>{option.icon}</span>
</Show>
<span>{option.label}</span>
<Show when={option.badge}>
<span class="ml-auto px-1.5 py-0.5 rounded text-xs
bg-gray-100 dark:bg-gray-700
text-gray-600 dark:text-gray-400">
{option.badge}
</span>
</Show>
</div>
<Show when={option.description}>
<p class="text-xs text-gray-500 dark:text-gray-500 mt-0.5">
{option.description}
</p>
</Show>
</button>
)}
</For>
</div>
</Show>
{/* Mensaje de error */}
<Show when={props.error}>
<p class="mt-1 text-xs text-red-600 dark:text-red-400">{props.error}</p>
</Show>
</div>
);
}El componente Select fue construido previamente en la sesión 056 para reemplazar más de 30 elementos <select> nativos en 16 archivos. Añadir modo oscuro a un componente compartido como este propaga el tema a cada página que lo utiliza.
Modal CreditBalance
El componente CreditBalance incluye un modal de recarga que requirió un tratamiento completo de modo oscuro:
tsxfunction TopUpModal() {
return (
<div class="fixed inset-0 z-50 flex items-center justify-center
bg-black/50 dark:bg-black/70">
<div class="bg-white dark:bg-gray-900
border border-gray-200 dark:border-gray-700
rounded-xl shadow-xl w-full max-w-md p-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
Add Funds
</h3>
{/* Montos preestablecidos */}
<div class="grid grid-cols-3 gap-2 mt-4">
<For each={[10, 25, 50, 100, 250, 500]}>
{(amount) => (
<button
class={`px-3 py-2 rounded-lg text-sm font-medium
${selectedAmount() === amount
? "bg-emerald-500 text-white"
: "bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300
hover:bg-gray-200 dark:hover:bg-gray-700"
}`}
onClick={() => setSelectedAmount(amount)}
>
${amount}
</button>
)}
</For>
</div>
{/* Input de monto personalizado */}
<input
type="number"
placeholder="Custom amount"
class="mt-3 w-full px-3 py-2 rounded-lg
bg-white dark:bg-gray-800
border border-gray-300 dark:border-gray-600
text-gray-900 dark:text-gray-100
placeholder-gray-500 dark:placeholder-gray-400"
/>
{/* Resumen */}
<div class="mt-4 p-3 rounded-lg bg-gray-50 dark:bg-gray-800
border border-gray-200 dark:border-gray-700">
<div class="flex justify-between text-sm text-gray-600 dark:text-gray-400">
<span>Amount</span>
<span>${selectedAmount()}</span>
</div>
<div class="flex justify-between text-sm font-medium
text-gray-900 dark:text-gray-100 mt-1">
<span>Total</span>
<span>${selectedAmount()}</span>
</div>
</div>
{/* Acciones */}
<div class="flex gap-3 mt-6">
<button class="flex-1 px-4 py-2 rounded-lg
border border-gray-300 dark:border-gray-600
text-gray-700 dark:text-gray-300
hover:bg-gray-50 dark:hover:bg-gray-800">
Cancel
</button>
<button class="flex-1 px-4 py-2 rounded-lg
bg-emerald-500 text-white
hover:bg-emerald-600">
Add Funds
</button>
</div>
</div>
</div>
);
}Exclusiones intencionales
Cinco componentes fueron dejados intencionalmente sin actualizaciones de modo oscuro, cada uno por una razón documentada:
| Componente | Razón |
|---|---|
WalletCard.tsx | Ya usa gradientes oscuros (slate-800/900) |
SyntaxHighlighter.tsx | Ya tiene tema oscuro (fondo slate-900) |
Sidebar.tsx | Ya usa fondo oscuro (indigo-950/slate-900) |
SuspensionBanner.tsx | Color rojo fijo para máxima visibilidad |
TestModeBanner.tsx | Color ámbar fijo para máxima visibilidad |
Estas exclusiones fueron deliberadas. La barra lateral siempre es oscura independientemente del tema (un patrón común en aplicaciones de panel de control). Los banners de advertencia mantienen sus colores de alerta porque la visibilidad supera a la consistencia del tema.
Resumen del modo oscuro por página del panel
Cada una de las 19 páginas del panel recibió actualizaciones de modo oscuro:
| Página | Clases dark añadidas | Elementos clave |
|---|---|---|
| Dashboard | 87 | Tarjetas de estadísticas, gráficos, transacciones recientes |
| Apps | 131 | Tarjetas de apps, visualización de claves API, config. de proveedores |
| Transactions | 96 | Filas de tabla, filtros, insignias de estado |
| Customers | 72 | Lista de clientes, indicadores de país |
| Wallet | 58 | Tarjeta de saldo, historial de transacciones |
| Settings | 103 | Inputs de formulario, encabezados de sección, pestañas |
| GetStarted | 89 | Stepper, bloques de código, cajas de información |
| Webhooks | 64 | Lista de eventos, formularios de configuración |
| SDKs | 51 | Tarjetas de SDK, ejemplos de código |
| DeveloperConsole | 112 | Pestañas, editor de código, visualización de respuestas |
| PaymentLinks | 67 | Tarjetas de enlaces, formulario de creación |
| PaymentMethods | 55 | Grilla de métodos, insignias de operadores |
| Countries | 48 | Tarjetas de países, filtros de región |
| Providers | 61 | Tarjetas de proveedores, indicadores de estado |
| Invoices | 59 | Tabla de facturas, insignias de estado |
| Teams | 43 | Lista de miembros, insignias de rol |
| AddFunds | 52 | Grilla de montos, formulario de pago |
| CreditHistory | 39 | Tabla de transacciones |
| FeatureRequests | 71 | Tarjetas de solicitudes, votación, comentarios |
El fondo a nivel de aplicación
La sesión 078 también cambió el fondo raíz del panel de control:
tsx// App.tsx
function DashboardLayout(props) {
return (
<div class="flex h-screen bg-gray-100 dark:bg-gray-950">
<Sidebar />
<div class="flex-1 flex flex-col overflow-hidden">
<Header />
<main class="flex-1 overflow-auto p-6">
{props.children}
</main>
</div>
</div>
);
}bg-gray-950 es el gris más oscuro de la paleta de Tailwind -- casi negro pero con suficiente tinte azul para evitar el negro puro severo.
Verificación de build
Después de todos los cambios de modo oscuro, el build fue verificado:
Build successful: 1,280 KB bundle, 7.46s
No TypeScript errorsLas adiciones del modo oscuro no incrementaron significativamente el bundle CSS porque el compilador JIT de TailwindCSS solo genera las clases de utilidad que realmente se usan.
Lo que aprendimos
Implementar el modo oscuro en 19 páginas nos enseñó tres cosas:
- Los patrones sistemáticos previenen la inconsistencia. Al establecer un mapeo fijo (light gray-100 -> dark gray-950, white -> gray-900, etc.) y aplicarlo uniformemente, cada página se ve consistente sin requerir una revisión visual píxel por píxel de cada una.
- Los componentes compartidos multiplican el esfuerzo. Actualizar el componente Select una vez propagó el modo oscuro a más de 30 inputs select en 16 archivos. Invertir en componentes UI compartidos y pulidos rinde dividendos al hacer cambios a nivel de plataforma.
- Documenta tus exclusiones. Los cinco componentes excluidos intencionalmente fueron documentados con sus razones. Sin esta documentación, el próximo desarrollador (o IA) intentaría "arreglarlos" y potencialmente romper el diseño visual.
La sesión 078 fue una sesión puramente de UI -- sin cambios de API, sin nuevas funcionalidades, solo modo oscuro aplicado sistemáticamente en todo el panel de control. El resultado fue un panel profesional consciente del tema que respeta las preferencias de los desarrolladores y reduce la fatiga visual durante las sesiones de depuración nocturnas.
Este artículo es parte de la serie "Cómo construimos 0fee.dev". 0fee.dev es un orquestador de pagos que cubre más de 53 proveedores en más de 200 países, construido por Juste A. GNIMAVO y Claude desde Abiyán sin ingenieros humanos. Sigue la serie para conocer la historia completa de la construcción.