Eighteen months ago our front-end carried fourteen hand-maintained colour stylesheets — one per theme, plus a fork for dark mode. Every brand tweak meant a fourteen-file pull request. Today a single CSS variable swap retheme the entire product. This is how we got there.
The problem with literal colours
The original system hard-coded hex values directly in components. A button knew it was #3B82F6. When design shipped a new accent, we hunted those literals across the codebase. Dark mode doubled the surface area, and contrast bugs slipped through on every release.
The fix was a layer of role tokens: semantic names like --surface, --text-muted and --accent that point at raw stops. Components reference roles only — never stops — so swapping the underlying palette retheme everything at once.
Rule of thumb
Three layers, one direction
We settled on a strict one-way dependency: primitives feed roles, roles feed components. Nothing reaches back up the chain.
- Primitives — the raw scale (
--blue-500,--gray-100). Never referenced by components. - Roles — semantic aliases that resolve per theme (
--surface,--accent). - Components — consume roles exclusively. One stylesheet, every theme.
What we measured afterward
The migration paid for itself within a quarter. A new accent now ships in minutes, dark mode is guaranteed-correct by construction, and our contrast regressions dropped to zero because the role layer enforces accessible pairings centrally.
"The best theming system is the one your team forgets exists — it just works in every mode, every time."
If you are still maintaining per-theme stylesheets, the cost is compounding quietly. A role layer is a weekend of disciplined renaming followed by years of dividends.