Back to 0fee
0fee

Dark Mode Across 19 Dashboard Pages

How we systematically implemented dark mode across 19 dashboard pages in 0fee.dev with consistent patterns and intentional exclusions.

Thales & Claude | March 25, 2026 9 min 0fee
dark-modetailwindcssui-designaccessibilitysystematic-styling

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:

CategoryCountRange of Changes
Dashboard pages1939-373 dark classes each
UI components6 of 11Varies
Auth/payment pages7All updated
Intentionally excluded5 componentsAlready 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

ElementLightDark
Page backgroundbg-gray-100dark:bg-gray-950
Card backgroundbg-whitedark:bg-gray-900
Input backgroundbg-whitedark:bg-gray-800
Hover backgroundhover:bg-gray-50dark:hover:bg-gray-800
Selected rowbg-emerald-50dark:bg-emerald-900/20
Table headerbg-gray-50dark:bg-gray-800/50

Text Colors

ElementLightDark
Primary texttext-gray-900dark:text-gray-100
Secondary texttext-gray-600dark:text-gray-400
Muted texttext-gray-500dark:text-gray-500
Link texttext-emerald-600dark:text-emerald-400

Borders

ElementLightDark
Card borderborder-gray-300dark:border-gray-700
Input borderborder-gray-300dark:border-gray-600
Dividerborder-gray-200dark:border-gray-800
Focus ringfocus:border-emerald-500dark: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:

ComponentReason
WalletCard.tsxAlready uses dark gradients (slate-800/900)
SyntaxHighlighter.tsxAlready dark-themed (slate-900 background)
Sidebar.tsxAlready uses dark background (indigo-950/slate-900)
SuspensionBanner.tsxFixed red color for maximum visibility
TestModeBanner.tsxFixed 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:

PageDark Classes AddedKey Elements
Dashboard87Stats cards, charts, recent transactions
Apps131App cards, API key display, provider config
Transactions96Table rows, filters, status badges
Customers72Customer list, country indicators
Wallet58Balance card, transaction history
Settings103Form inputs, section headers, tabs
GetStarted89Stepper, code blocks, info boxes
Webhooks64Event list, configuration forms
SDKs51SDK cards, code examples
DeveloperConsole112Tabs, code editor, response display
PaymentLinks67Link cards, creation form
PaymentMethods55Method grid, operator badges
Countries48Country cards, region filters
Providers61Provider cards, status indicators
Invoices59Invoice table, status badges
Teams43Member list, role badges
AddFunds52Amount grid, payment form
CreditHistory39Transaction table
FeatureRequests71Request 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 errors

The 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:

  1. 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.
  1. 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.
  1. 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.

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles