Designing a color system that survives dark mode
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.