Skip to content
Development

Dark mode in an afternoon when your CSS is already tokens (and the false alarm that wasn't)

By Victor Da Luz
railscsstailwinddev-logblog-manager

My blog-manager app was light-only. I wanted a real dark mode with a toggle. Two things made this interesting: it was genuinely fast because of a decision I’d made months earlier, and I then wasted most of the saved time “fixing” something that already worked.

The part that was easy on purpose

The whole app is styled off CSS custom-property tokens. Tailwind v4 lets you define them in a @theme block:

@theme {
  --color-page: #f5f3ee;
  --color-card: #ffffff;
  --color-text-1: #18181a;
  /* ... */
}

Every utility (bg-card, text-text-1, border-border) compiles to var(--color-card) and friends. Which means a whole dark theme is just redefining those variables under a class:

.dark {
  --color-page: #1a1b26;
  --color-card: #1f2335;
  --color-text-1: #c0caf5;
  /* Tokyo Night the rest */
}

Add a .dark class to <html> and the entire app flips. No touching components, no dark: on a thousand elements. I went with the Tokyo Night palette because it’s the one I stare at in my editor all day, and kept my brand’s burnt-orange accent, just brightened so it doesn’t go muddy on the dark blue.

Three things didn’t flip for free, and they’re the interesting ones:

  • Components that use a text token as a background. My primary button was background: var(--color-text-1) with light text. In light mode that’s a near-black button. Flip the tokens and --color-text-1 becomes light, so the button turns into light-text-on-light-background: invisible. Any “use the text color as a fill” trick inverts wrong. I had to override those by hand.
  • Hardcoded colors that aren’t tokens. A handful of pastel icon backgrounds and a couple of rgba(0,0,0,0.015) hover tints (invisible on a dark surface). Small, but they don’t come along for the ride.
  • The flash of light on load. If the dark class is applied by JavaScript after the page renders, you get a white flash first. The fix is a tiny inline script in <head>, before the stylesheet, that reads the saved preference (or the OS one) and sets the class before first paint.

The toggle itself is a five-line Stimulus controller: flip the class, write localStorage. Defaults to your OS preference, remembers your choice after that.

The part where I argued with a working feature

I wrote a system test (real headless browser): log in, click the toggle, assert the <html> element now has dark. It failed. No dark class.

So I started debugging the toggle. Was the Stimulus controller even loading? I had the browser print its registered controllers: ["hello", "postiz-schedule", "theme"]. There it was, registered. But clicking it did nothing. I stared at a trivial five-line controller convinced it was broken.

It wasn’t. Three unrelated things were stacked on top of each other, and each one sent me down a wrong path:

  1. Stale CSS. My first screenshots showed both the sun and moon icons at once, and the page stayed light. That wasn’t the toggle. The test runner was serving an old compiled stylesheet that didn’t contain my new .dark rules at all, so nothing could flip and no dark: utility existed to hide the wrong icon. A rebuild fixed the visuals instantly. But I’d already convinced myself the toggle was the problem.
  2. I clicked too fast. Controllers load asynchronously through the import map. My test clicked the button about 100ms after the page loaded, before the controller had connected. A human will never do that. The test was the only “user” impatient enough to lose that race. Waiting for the controller to register before clicking made it pass.
  3. The headless browser prefers dark. Once it passed, a different assertion failed: I expected the app to start in light mode, and it started dark. Turns out the headless Chrome reports a dark OS preference, so my no-flash script was correctly honoring it. My test’s assumption was wrong, not the code. (A persisted localStorage value left over from an earlier run had been muddying this too, because the browser doesn’t clear local storage between test runs.)

Every single failure was real, and not one of them was the thing I was debugging. The toggle worked from the first commit. I spent 45 minutes proving a five-line function innocent.

What I took from it

Design your tokens once and reskinning is almost free. The afternoon-long dark mode is the payoff for a boring decision months ago to route every color through a CSS variable. Future-me thanks past-me roughly once a quarter.

When the test fails, suspect the test. Especially in a browser. Stale assets, async loading, and the headless environment’s own settings (OS theme, locale, storage that survives between runs) produce failures that look exactly like application bugs. I kept “fixing” the feature because the failure pointed at it, when the honest move was to ask what the test environment was doing differently from a real user. The tell was right there: the controller was registered and the function was three lines. That should have redirected me immediately, and didn’t.

Light mode still ships as the default for anyone whose OS asks for it. I just don’t have to use it anymore.

Related reading

Development

"Looks the same to me"

A UI redesign, a review comment that named the exact property I never touched, and what it takes to actually fix what someone reports instead of what's around it.

Read