All articles
Design & UXMay 26, 2026 6 min read

Dark Mode Color Tokens: Why Your Brand Palette Falls Apart at Night

Most dark modes are a light theme with the lights off. Here's how to build a token system that keeps brand identity, passes WCAG, and doesn't make your buttons glow like radioactive candy.

Every team we audit has the same dark mode story: a designer inverted the background, the brand blue stayed put, and now the primary button looks like it's about to take off. Dark mode isn't a filter — it's a parallel color system with its own contrast math, its own perception rules, and its own tokens. Treat it like one and the rest of the work gets easier.

The mistake: inverting a light palette

The default move is to flip bg-white to bg-zinc-950, keep brand colors as-is, and call it shipped. It looks fine in Figma at 100% zoom. It falls apart on a real OLED screen at 2am because of three things that designers rarely talk about together:

  1. Pure black is too contrasty. On OLED panels, #000000 against saturated text creates halation — that buzzing edge effect that makes reading painful after a paragraph.
  2. Saturated brand colors vibrate on dark surfaces. A 100%-saturation blue that looked corporate on white reads as neon on near-black. Your eyes literally can't focus on the edges.
  3. Contrast ratios flip asymmetrically. Text that hits 7:1 on white might only hit 4.2:1 on your chosen dark background, even if the hex looks "dark enough".

If you're inverting, you're not designing dark mode. You're hoping.

Build two palettes, not one

The cleanest mental model: your design system has two color modes, and tokens are the contract between them. The brand identity lives in the semantic layer, not the raw layer.

Raw vs semantic tokens

Raw tokens are the literal colors. Semantic tokens are what components actually consume.

{
  "color": {
    "raw": {
      "blue": {
        "500": "#2563eb",
        "400": "#3b82f6",
        "300": "#60a5fa"
      },
      "neutral": {
        "950": "#0a0a0b",
        "900": "#141416",
        "850": "#1c1c20"
      }
    },
    "semantic": {
      "bg": {
        "surface": {
          "light": "{color.raw.neutral.50}",
          "dark": "{color.raw.neutral.900}"
        }
      },
      "action": {
        "primary": {
          "light": "{color.raw.blue.500}",
          "dark": "{color.raw.blue.400}"
        }
      }
    }
  }
}

Notice the primary action uses a lighter, less saturated blue in dark mode. That's not an accident — it's the rule.

The three rules that actually matter

After rebuilding dark themes for a handful of B2B dashboards and one consumer mobile app, three rules show up every time. Skip them and you're back in the glowing-button trap.

Rule 1: Never use pure black for the canvas

Use a deep neutral with a tiny hue bias — usually toward the brand. For a blue-leaning product, something around #0d0e12 reads as "dark" without the OLED halation. For warmer brands, drift toward #121110. The number you're chasing is roughly 8-12% lightness in HSL, not 0%.

This gives you headroom: you can layer above the canvas (modals, cards, popovers) by going slightly lighter rather than slightly darker, which is what your eyes expect from a light source overhead.

Rule 2: Desaturate brand colors by 10-25%

This is the hardest sell to brand-protective stakeholders. The pitch: the perceived color stays the same because human color perception adapts to the surrounding luminance. A blue at 60% saturation on dark feels as blue as the same hue at 85% saturation on white. We've measured this in user tests by asking people to match swatches across modes — they consistently pick the desaturated version as "the brand color" once it's on dark.

For accent colors used in small areas (badges, chart points, icons), you can keep more saturation. For large fills — buttons, banners, hero blocks — back it off.

Rule 3: Elevate with lightness, not shadow

Drop shadows barely register on dark backgrounds. Elevation in dark mode is communicated by lighter surfaces, not darker shadows. Your token system should have at least three elevation levels:

:root[data-theme="dark"] {
  --bg-canvas: #0d0e12;
  --bg-surface-1: #15171c;  /* cards */
  --bg-surface-2: #1d2027;  /* popovers, modals */
  --bg-surface-3: #262932;  /* tooltips, top-most */
}

The steps are small — about 4-6 lightness points each. Bigger jumps look cartoonish. Smaller jumps disappear.

Checking contrast without losing your mind

WCAG 2.2 AA wants 4.5:1 for body text and 3:1 for large text and UI components. AAA wants 7:1. The trap in dark mode is that contrast ratios are not symmetric — a hex pair that scores 5:1 in light mode might score 4.1:1 when you swap the foreground and background relationship.

We automate this in CI. Every PR that touches the token file runs a contrast check against the semantic pairs that matter:

// scripts/check-contrast.mjs
import { getContrast } from 'polished';
import tokens from '../tokens/semantic.json' assert { type: 'json' };

const pairs = [
  ['text.primary', 'bg.surface', 4.5],
  ['text.secondary', 'bg.surface', 4.5],
  ['action.primary.fg', 'action.primary.bg', 4.5],
  ['border.default', 'bg.surface', 3.0],
];

const modes = ['light', 'dark'];
let failed = false;

for (const mode of modes) {
  for (const [fg, bg, min] of pairs) {
    const ratio = getContrast(
      resolve(fg, mode),
      resolve(bg, mode)
    );
    if (ratio < min) {
      console.error(`✗ ${mode}: ${fg} on ${bg} = ${ratio.toFixed(2)} (need ${min})`);
      failed = true;
    }
  }
}

process.exit(failed ? 1 : 0);

This catches the regression where someone tweaks a hex "just a little lighter" and silently breaks AA on the secondary text. Run it on every token change. It takes milliseconds and saves audits.

The Tailwind handoff

If you're on Tailwind 4, the CSS-variable model makes this clean. Define semantic tokens as variables, scope them by data-theme, and let utilities reference them:

@theme {
  --color-surface: var(--bg-surface);
  --color-surface-raised: var(--bg-surface-1);
  --color-action: var(--action-primary);
  --color-action-fg: var(--action-primary-fg);
}

:root {
  --bg-surface: #fafafa;
  --bg-surface-1: #ffffff;
  --action-primary: #2563eb;
  --action-primary-fg: #ffffff;
}

:root[data-theme="dark"] {
  --bg-surface: #0d0e12;
  --bg-surface-1: #15171c;
  --action-primary: #3b82f6;
  --action-primary-fg: #0a0a0b;
}

Now bg-surface and bg-action work in both modes without any dark: prefixes scattered through your JSX. Components stop knowing about themes entirely — they consume semantic tokens, and the theme decides what those resolve to.

Edge cases that bite

A few things that look fine in isolation but break in production:

  • Brand gradients. A gradient from saturated brand-purple to brand-pink looks luxe on white and like a 2007 MySpace page on dark. Build a separate dark-mode gradient — usually with more black mixed into the stops.
  • Images with transparent backgrounds. Logos, product shots, illustrations. If they were designed for white, they'll have invisible edges or unreadable details on dark. Either add a subtle backdrop token (bg-surface-1 works) or ship dark variants.
  • Form fields. Pure-white inputs on a dark canvas scream. Inputs should sit slightly above the canvas in lightness, with a border that's lighter still. Counter-intuitive but correct.
  • Disabled states. Lowering opacity works on white. On dark, low-opacity text disappears into the background. Use a dedicated text.disabled token with a real contrast value (around 2.5:1 is the sweet spot — visible, clearly off).
  • Charts and data viz. Categorical palettes need a full second set. Don't try to reuse the light palette with opacity tricks; it kills the legibility of small marks.

What we'd do

If you're starting a dark mode pass next sprint, do it in this order. Audit your current palette for the three failure modes — pure-black canvas, oversaturated brand fills, shadow-based elevation. Split your tokens into raw and semantic if they aren't already; the semantic layer is where dark mode lives. Build a contrast check into CI before you touch a single component, so regressions can't sneak in while you're refactoring. Then desaturate the brand colors by feel, test the result against real screens (not just your laptop), and ship behind a feature flag for a week of dogfooding.

Dark mode isn't a coat of paint. It's a second design system that happens to share component code. Build it that way and you'll stop apologizing for the buttons.

If you want a hand auditing your token system or rebuilding a theme that scales, that's the kind of work our design and engineering team does day to day.

#Design Systems#Accessibility#Color#Design Tokens#Dark Mode

Want a team like ours?

72Technologies builds production software for the kind of teams who actually read this blog.

Start a project