Dark mode is not a feature you bolt on at the end. It touches every component, every background color, every border, every text shade, every input field. When we implemented dark mode for 0fee.dev's dashboard in Session 078, it meant updating 19 pages, 6 shared components, and 7 auth/payment pages -- a systematic sweep that added hundreds of dark: utility classes following consistent patterns.
This article covers the systematic approach, the Tailwind dark mode patterns we standardized on, the Select component's 44+ dark classes, the CreditBalance modal's full dark treatment, and the intentional exclusions that we documented and left alone.
The Scale of the Problem
Before Session 078, the dashboard existed in light mode only. Dark mode support required updating:
| Category | Count | Range of Changes |
|---|---|---|
| Dashboard pages | 19 | 39-373 dark classes each |
| UI components | 6 of 11 | Varies |
| Auth/payment pages | 7 | All updated |
| Intentionally excluded | 5 components | Already dark-themed |
A "dark class" means a TailwindCSS utility like dark:bg-gray-900 or dark:text-gray-200. Each one specifies what a property should be when the user's theme preference is dark.
Dark Mode Configuration
TailwindCSS supports dark mode via the class strategy, where adding a dark class to the root element activates all dark: prefixed utilities:
javascript// tailwind.config.js
module.exports = {
darkMode: "class",
// ...
};The theme toggle persists in localStorage:
typescriptfunction toggleDarkMode() {
const isDark = document.documentElement.classList.toggle("dark");
localStorage.setItem("theme", isDark ? "dark" : "light");
}
// On page load
const saved = localStorage.getItem("theme");
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
if (saved === "dark" || (!saved && prefersDark)) {
document.documentElement.classList.add("dark");
}Standardized Dark Patterns
We established consistent mappings for every element type:
Backgrounds
| Element | Light | Dark |
|---|---|---|
| Page background | bg-gray-100 | dark:bg-gray-950 |
| Card background | bg-white | dark:bg-gray-900 |
| Input background | bg-white | dark:bg-gray-800 |
| Hover background | hover:bg-gray-50 | dark:hover:bg-gray-800 |
| Selected row | bg-emerald-50 | dark:bg-emerald-900/20 |
| Table header | bg-gray-50 | dark:bg-gray-800/50 |
Text Colors
| Element | Light | Dark |
|---|---|---|
| Primary text | text-gray-900 | dark:text-gray-100 |
| Secondary text | text-gray-600 | dark:text-gray-400 |
| Muted text | text-gray-500 | dark:text-gray-500 |
| Link text | text-emerald-600 | dark:text-emerald-400 |
Borders
| Element | Light | Dark |
|---|---|---|
| Card border | border-gray-300 | dark:border-gray-700 |
| Input border | border-gray-300 | dark:border-gray-600 |
| Divider | border-gray-200 | dark:border-gray-800 |
| Focus ring | focus:border-emerald-500 | dark:focus:border-emerald-400 |
Status Colors
Status badges maintain their semantic colors in both modes, with adjusted opacity for dark backgrounds:
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>
);
}The Select Component: 44+ Dark Classes
The custom Select component (Select.tsx) was the most complex dark mode update, requiring 44+ dark classes across 13 different elements:
tsxfunction Select(props: SelectProps) {
return (
<div class="relative">
{/* Label */}
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{props.label}
</label>
{/* Trigger button */}
<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>
{/* Dropdown */}
<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>
{/* Error message */}
<Show when={props.error}>
<p class="mt-1 text-xs text-red-600 dark:text-red-400">{props.error}</p>
</Show>
</div>
);
}The Select component was previously built in Session 056 to replace 30+ native <select> elements across 16 files. Adding dark mode to a shared component like this propagates the theme to every page that uses it.
CreditBalance Modal
The CreditBalance component includes a top-up modal that required full dark mode treatment:
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>
{/* Preset amounts */}
<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>
{/* Custom amount input */}
<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"
/>
{/* Summary */}
<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>
{/* Actions */}
<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>
);
}The transaction history within the CreditBalance component also received dark mode treatment:
tsx<div class="divide-y divide-gray-200 dark:divide-gray-700">
<For each={transactions()}>
{(tx) => (
<div class="flex justify-between py-3">
<div>
<span class="text-sm text-gray-900 dark:text-gray-100">{tx.description}</span>
<span class="text-xs text-gray-500 dark:text-gray-500 block">{tx.date}</span>
</div>
<span class={tx.amount > 0
? "text-emerald-600 dark:text-emerald-400"
: "text-red-600 dark:text-red-400"
}>
{tx.amount > 0 ? "+" : ""}{tx.amount}
</span>
</div>
)}
</For>
</div>Intentional Exclusions
Five components were intentionally left without dark mode updates, each for a documented reason:
| Component | Reason |
|---|---|
WalletCard.tsx | Already uses dark gradients (slate-800/900) |
SyntaxHighlighter.tsx | Already dark-themed (slate-900 background) |
Sidebar.tsx | Already uses dark background (indigo-950/slate-900) |
SuspensionBanner.tsx | Fixed red color for maximum visibility |
TestModeBanner.tsx | Fixed amber color for maximum visibility |
These exclusions were deliberate. The sidebar is always dark regardless of theme (a common pattern in dashboard apps). Warning banners maintain their alert colors because visibility trumps theme consistency.
Dashboard Page Dark Mode Summary
Each of the 19 dashboard pages received dark mode updates:
| Page | Dark Classes Added | Key Elements |
|---|---|---|
| Dashboard | 87 | Stats cards, charts, recent transactions |
| Apps | 131 | App cards, API key display, provider config |
| Transactions | 96 | Table rows, filters, status badges |
| Customers | 72 | Customer list, country indicators |
| Wallet | 58 | Balance card, transaction history |
| Settings | 103 | Form inputs, section headers, tabs |
| GetStarted | 89 | Stepper, code blocks, info boxes |
| Webhooks | 64 | Event list, configuration forms |
| SDKs | 51 | SDK cards, code examples |
| DeveloperConsole | 112 | Tabs, code editor, response display |
| PaymentLinks | 67 | Link cards, creation form |
| PaymentMethods | 55 | Method grid, operator badges |
| Countries | 48 | Country cards, region filters |
| Providers | 61 | Provider cards, status indicators |
| Invoices | 59 | Invoice table, status badges |
| Teams | 43 | Member list, role badges |
| AddFunds | 52 | Amount grid, payment form |
| CreditHistory | 39 | Transaction table |
| FeatureRequests | 71 | Request cards, voting, comments |
The App-Level Background
Session 078 also changed the root dashboard background:
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 is the darkest gray in Tailwind's palette -- almost black but with enough blue tint to avoid harsh pure black.
Build Verification
After all dark mode changes, the build was verified:
Build successful: 1,280 KB bundle, 7.46s
No TypeScript errorsThe dark mode additions did not increase the CSS bundle significantly because TailwindCSS's JIT compiler only generates the utility classes that are actually used.
What We Learned
Implementing dark mode across 19 pages taught us three things:
- Systematic patterns prevent inconsistency. By establishing a fixed mapping (light gray-100 -> dark gray-950, white -> gray-900, etc.) and applying it uniformly, every page looks consistent without requiring pixel-perfect design review of each one.
- Shared components amplify effort. Updating the Select component once propagated dark mode to 30+ select inputs across 16 files. Investing in shared UI components pays dividends when making platform-wide changes.
- Document your exclusions. The five intentionally excluded components were documented with reasons. Without this documentation, the next developer (or AI) would try to "fix" them and potentially break the visual design.
Session 078 was a pure UI session -- no API changes, no new features, just dark mode applied systematically across the entire dashboard. The result was a professional, theme-aware dashboard that respects developer preferences and reduces eye strain during late-night debugging sessions.
This article is part of the "How We Built 0fee.dev" series. 0fee.dev is a payment orchestrator covering 53+ providers across 200+ countries, built by Juste A. GNIMAVO and Claude from Abidjan with zero human engineers. Follow the series for the complete build story.