Building a Color System That Works Across Light, Dark, and Brand Themes

The first version of a SaaS client's product used raw hex values scattered directly in components — #1A73E8 for primary buttons, #F5F5F5 for backgrounds, and so on. When they asked for dark mode a few months post-launch, engineering had to hunt down every hardcoded value individually, and several got missed, resulting in a dark-mode release with a handful of components that stayed stubbornly light and unreadable.
For every subsequent project, we now build color as a two-layer token system from day one, even for clients with no immediate dark mode request. The first layer is primitive colors — the actual palette, blue-500, grey-100, and so on. The second layer is semantic tokens that reference the primitives — background-primary, text-secondary, border-error — and every component only ever references semantic tokens, never primitives directly. Adding a theme becomes a matter of remapping the semantic layer, not touching component code.
Dark mode isn't just light mode's colors inverted, and treating it that way produces washed-out, low-contrast results. Pure white text on pure black background causes visible halation for a lot of users; we use an off-black background (#121212-ish, not #000000) and slightly desaturated accent colors, since fully saturated brand colors that look fine on white tend to vibrate uncomfortably against a dark background.
Brand theming added a third layer for a white-label client project, where each customer could apply their own accent color to the product. We constrained which tokens were brand-overridable — primary buttons and links, yes; semantic colors like error-red and success-green, never — because letting a customer's brand color override a status color risks a red 'delete' button that happens to be the customer's cheerful brand blue, which is actively dangerous for a destructive action.
Contrast checking became part of the token definition process itself rather than a manual QA pass at the end. Every semantic pairing (text on background) gets validated against WCAG AA automatically when a token changes, which caught several near-miss combinations before they ever reached a real screen, instead of after a client's accessibility audit flagged them.


