QUANHEX.
Engineering

Designing for Dark Mode: Beyond CSS Color Inversion

Dark mode done well is not simply inverted colors. Here's how to design a dark mode system that looks intentional, not accidental.

· 5 min read

Dark mode is now a user expectation, not a differentiator. What separates a dark mode that feels designed from one that was bolted on is the same thing that separates any good design from a bad one: intentionality.

The Inversion Trap

The naive dark mode approach: invert the light mode colors. White becomes black, dark gray becomes light gray. It seems logical. It produces universally mediocre results.

The problem: light mode and dark mode are fundamentally different visual environments. Light mode reflects light from a bright background. Dark mode emits light from a dark surface. The same visual hierarchy rules don’t automatically translate.

Shadows, for example: on a light background, shadows are darker than the surface. On a dark background, shadows would be invisible. The dark mode equivalent of shadow for hierarchy is elevation via lighter surfaces — a card that sits “above” its background is slightly lighter than the background, not darker.

CSS Custom Properties Are the Foundation

Dark mode implementation that doesn’t use CSS custom properties is a maintenance problem waiting to happen.

:root {
  --bg: #ffffff;
  --bg-card: #f8f9fb;
  --text-primary: #0f172a;
  --text-secondary: #475569;
  --accent: #2563eb;
}

@media (prefers-color-scheme: dark) {
  :root {
    --bg: #0a0f1e;
    --bg-card: #111827;
    --text-primary: #f1f5f9;
    --text-secondary: #94a3b8;
    --accent: #3b82f6;
  }
}

Every color in the application references a variable. Switching the mode requires only the variable overrides — no duplicated component styles.

Elevation in Dark Mode

Instead of CSS box-shadow (which is largely invisible on dark backgrounds), use surface color to communicate elevation:

/* Ground level */
--bg: #0a0f1e;

/* Cards (elevated above ground) */
--bg-card: #111827;

/* Popovers (elevated above cards) */
--bg-popover: #1e293b;

/* Tooltips (highest elevation) */
--bg-tooltip: #334155;

Each elevated surface is progressively lighter. A card reads as “in front of” the page without any shadow.

The Accent Color Problem

Your brand’s primary blue at full saturation may be the right choice on a white background and the wrong choice on a dark background. Dark backgrounds make fully saturated colors look harshly bright — they vibrate against the dark surface.

The solution: slightly adjust accent colors in dark mode. More toward the lighter end of the same hue family, and often slightly less saturated.

/* Light mode: strong blue */
--accent: #2563eb;

/* Dark mode: lighter, slightly softer blue */
--accent: #3b82f6;

The change is subtle but meaningful — the dark mode accent looks intentional rather than harsh.

Images and Illustrations

Photography works in both modes without adjustment — it has its own internal contrast that isn’t affected by background color. Illustrations and icons often don’t.

Line icons designed for light mode may disappear on dark backgrounds if they’re dark lines on transparent backgrounds. SVG icons should use currentColor so they inherit the surrounding text color.

Illustrations with white fills will have those fills show up as jarring white blocks in dark mode. Either design separate dark mode versions, or use CSS to adjust illustration assets:

@media (prefers-color-scheme: dark) {
  .illustration { filter: brightness(0.85) contrast(1.1); }
}

This is a blunt instrument — for brand-critical illustrations, the separate asset approach is worth the effort.

Testing Dark Mode

The most important dark mode test: view your application at 10pm in a darkened room. If it causes eye strain or looks obviously wrong, it needs work. Dark mode exists for user comfort in low-light environments. If your implementation is harsher than a white page, you’ve solved the wrong problem.

Automated contrast checking tools (Lighthouse, axe) check contrast in the mode you’re testing in. Run contrast checks explicitly against your dark mode color values, not just light mode.