KK
Kenta Kodashima
← All posts
May 18, 20262 min read

Designing a color system that survives dark mode

design-systemscss

Most color systems are fine until someone flips on dark mode. Then every hard-coded gray becomes a decision you have to make twice — and slowly, the two themes drift apart.

Stop hard-coding hex values

The root problem is that components reference raw colors directly. The moment a component says background is #FFFFFF, it has made an assumption that only holds in one theme.

:root {
  --surface: #ffffff;
  --text: #18181b;
}
[data-theme="dark"] {
  --surface: #161618;
  --text: #ececea;
}

/* component just reads the token */
.card {
  background: var(--surface);
  color: var(--text);
}

Semantic tokens beat raw colors

I keep three layers: raw values (the palette), semantic tokens (surface, text, border, accent), and the occasional component token. Components only ever touch the semantic layer.

  • Raw: the full ramp, never used directly in UI.
  • Semantic: surface / text / muted / border / accent — themed once.
  • Component: rare overrides, defined in terms of semantic tokens.

Test both themes from day one

If dark mode is a stage-two project, it will always feel bolted on. Render both themes side by side in your component explorer and treat a broken dark variant as a broken component.

A color system is not a palette. It is a set of promises about contrast that hold no matter which theme is on.

← Previous
The real cost of a re-render