I added dark mode by editing CSS variables - not 100 components
My app had ~1,200 hardcoded indigo-* and ~2,600 hardcoded slate-* class usages across
130-odd components. The brief was small to say and brutal to do: "ship dark mode, and
let me rebrand the accent color whenever I want."
The naive plan is a sweep: open every component, add dark: variants, swap colors. That's
weeks of work, and worse, it's permanent work - every new component re-incurs it, and
the design drifts the moment two people touch it. I wanted dark mode to be a property of
the theme, not of every component.
The decision
Two ideas, both "edit the tokens, not the markup":
1. One brand token. Instead of components knowing the color indigo, they reference a
semantic brand scale. I did a one-time codemod - indigo- → brand- across app/,
components/, lib/ (~1,230 mechanical edits, no logic change) - and defined the brand
scale once as CSS variables. Now recoloring the entire app, globally or per-user, is a
variable swap. No component ever needs editing again.
:root {
--brand-50:#EEF4FF; /* … */ --brand-500:#0066FF; --brand-600:#0052D6; /* … */
}
/* Tailwind v4: expose them so bg-/text-/border-/ring-brand-* all resolve to the vars */
@theme inline {
--color-brand-500: var(--brand-500);
--color-brand-600: var(--brand-600);
/* …the full scale… */
}
Per-user accent "vibes" then become trivial - each preset just re-defines that scale under
an attribute selector, so picking one recolors the app instantly with zero component churn:
html[data-accent="emerald"] { --brand-500:#059669; --brand-600:#047857; /* … */ }
2. Dark mode as a token remap, not a component sweep. This is the part that surprised
me.
The mechanism (and the wrong turn I took first)
My first attempt failed silently. I tried mapping Tailwind's slate scale through my own
indirection in @theme inline - and text stayed dark in dark mode. Tailwind's default
slate-* utilities weren't picking up my override.
Then I read the compiled CSS, and the trick fell out of it. Tailwind v4 emits its own
palette as real CSS variables, and the utilities reference them:
.text-slate-900 { color: var(--color-slate-900); }
So I don't need to touch a single utility. I just redefine the slate ramp inside the
.dark selector - reversed, so "dark text" becomes light and "light background" becomes
dark:
.dark {
--color-slate-900: #f1f5f9; /* was near-black → now near-white text */
--color-slate-700: #cbd5e1;
--color-slate-200: #3a4659; /* was light border → now a dark border */
--color-slate-50: #273345; /* was page-white → now an elevated surface */
color-scheme: dark; /* native scrollbars/date pickers follow too */
}
Every text-slate-900, bg-slate-50, border-slate-200 in the codebase flips
automatically. Thousands of usages, one block of CSS.
The gotcha
A reversed ramp isn't a straight inversion - surface hierarchy breaks if you're naive.
My first ramp made slate-50 darker than my card color, so every "subtle gray" inner box
(inside drawers and cards) suddenly looked like the page bleeding through the card. The
fix was to treat the low slate shades as elevated surfaces that sit above the card,
not below it: canvas (#0b1120) < card (#1e293b) < slate-50/100/200. Dark mode isn't
"flip the colors" - it's "preserve the depth ordering with a dark palette." Miss that and
everything looks flat and muddy.
A second, smaller one: native <select> option lists. Components set a dark background on
the control, but the option popup inherited the dark bg without a light text color -
dark-on-dark, invisible. One global rule fixed all of them at once:
select option { background-color: var(--card); color: var(--card-foreground); }
What I'd do next
- A no-flash inline script in
<head>to set the theme before first paint (right now a provider gates render, which avoids the flash but costs a frame). - Audit the handful of intentionally dark elements (a dark CTA, a tooltip) that I had to pin to fixed colors so the remap couldn't invert them - those are the exceptions that prove the rule.
- A contrast-ratio check in CI so a future accent preset can't ship an unreadable pair.
The meta-lesson: if changing a visual property means editing many files, the property is
in the wrong layer. Push color and theme into tokens, and "rebrand the app" or "add dark
mode" turns from a sprint into a diff.
What's your dark-mode strategy - dark: everywhere, CSS variables, or something else?
Top comments (0)