DEV Community

Pavel Gajvoronski
Pavel Gajvoronski

Posted on

Light Mode Was Lying to Us

How we migrated 30 pages from hardcoded zinc colors to semantic CSS tokens — and what broke along the way.

We shipped dark mode first, like most developers do. It looks great. Users loved it. Then someone asked for light mode.

"How hard can it be?" — famous last words.

The Setup

Kepion is a Next.js 15 app with Tailwind CSS. It has about 30 pages — dashboards, analytics, agent management, content pipelines, a real-time chat, pricing. The kind of app where you're always adding a new page and copy-pasting layout patterns from the last one.

Dark mode worked because we hardcoded zinc colors everywhere:

<div className="bg-zinc-900 border border-zinc-800 text-zinc-100">
  <span className="text-zinc-400">Secondary text</span>
</div>
Enter fullscreen mode Exit fullscreen mode

This is fine when you only have one theme. Every page looked consistent. We'd been doing this for months.

Then we added a theme switcher.

What We Had vs. What We Needed

Light mode with hardcoded zinc-900 backgrounds gives you: dark grey boxes on a light background. It looks like someone put a dark mode component inside a light mode shell. Which is exactly what it was.

The fix wasn't complicated, but it was large. We needed to replace every hardcoded zinc color with a semantic token that knows which mode it's in.

The token map we settled on:

/* globals.css — light mode (:root) */
--background: #F4F3EE;    /* warm cream, not pure white */
--card: #FFFFFF;          /* white cards/blocks */
--sidebar: #F9F8F4;       /* slightly warmer than bg */
--foreground: #1A1A1A;    /* near-black text */
--muted-foreground: #6B6B6B; /* secondary text */
--border: #E5E4DF;        /* warm grey borders */

/* .dark override */
--background: #09090B;
--card: #18181B;
--foreground: #FAFAFA;
/* etc. */
Enter fullscreen mode Exit fullscreen mode
/* Before */
<div className="bg-zinc-900 border border-zinc-800">

/* After */
<div className="bg-card border border-border">
Enter fullscreen mode Exit fullscreen mode

Now bg-card is white in light mode and dark in dark mode. The CSS variable switches when the .dark class toggles on <html>. Tailwind reads the variable. No dark: prefixes needed on every element.

The Flash Problem

If you switch themes based on a cookie or localStorage, there's a classic issue: the page renders before JS runs, so it flashes the wrong theme for ~50ms.

We fixed it with an inline script in <head> — before any render:

<script>
  const theme = localStorage.getItem('theme') || 'dark';
  document.documentElement.classList.toggle('dark', theme === 'dark');
</script>
Enter fullscreen mode Exit fullscreen mode

Inline scripts block rendering. That's normally bad. Here it's exactly what you want — the class is set before the first paint, so there's no flash.

The Badge Problem

Status badges were the sneaky part. We had patterns like:

<span className="bg-green-900/20 text-green-400">Active</span>
Enter fullscreen mode Exit fullscreen mode

In dark mode: perfect. Dark background, bright text.

In light mode: nearly invisible green tint on cream, with text that's too light to read.

The fix needs both modes explicitly:

<span className="bg-green-600/10 text-green-700 dark:bg-green-900/20 dark:text-green-400">
  Active
</span>
Enter fullscreen mode Exit fullscreen mode

text-green-700 is dark enough to read on cream. dark:text-green-400 stays bright for dark mode. The background tint is lighter (/10 vs /20) so it doesn't dominate on a light surface.

We had roughly 180 badge instances across 30 pages. Same pattern, repeated.

What We Learned

1. Semantic tokens from the start

If we'd used bg-card instead of bg-zinc-900 from day one, adding light mode would have been a CSS file change, not 34 files touched.

The temptation to hardcode is real — you know what zinc-900 looks like. You can predict it. Semantic tokens require mental indirection. But that indirection is the whole point.

2. Warm, not pure white

#FFFFFF backgrounds feel harsh. #F4F3EE (warm cream) reads as "designed". Small difference, noticeable effect. Cards stay white — the contrast between #F4F3EE background and #FFFFFF cards gives visual depth without dark shadows.

3. Batch the mechanical work

When you have a pattern that repeats 180 times, don't do it manually. We scripted the replacement of common zinc classes to their semantic equivalents. Grep is your friend:

rg "bg-zinc-900" --type tsx -l | head -20
Enter fullscreen mode Exit fullscreen mode

Find the pattern, confirm it's consistent, replace in bulk. Then audit the exceptions manually.

4. TypeScript catches translation errors, not theme errors

There's no type error when you write text-zinc-400. Tailwind doesn't know it'll look wrong in light mode. The only way to catch it is to actually look at the page in light mode. Build something, switch the theme, look at every page. It's not automatable.

What's Next

The theming system works, but it's still a convention — not enforced. A new developer (or a tired session of autocomplete) can still write bg-zinc-900 and it'll silently break light mode.

What would make this bulletproof:

  • A Tailwind plugin or ESLint rule that flags raw zinc/slate/gray colors in component files
  • A Storybook (or equivalent) that renders every component in both modes
  • Visual regression tests that screenshot both themes on every PR

We don't have any of those yet. For a fast-moving solo project, they'd have slowed us down. But at some point the maintenance cost of un-enforced conventions exceeds the cost of enforcement. We're not there yet — but it's coming.


Kepion is an AI-powered company builder. One subscription gets you a full team of 31 specialized AI agents — strategy, content, development, marketing, finance — all orchestrated to build and run real businesses autonomously.

This is part of an ongoing series about what we're actually building and how we're solving the hard parts.


Over to you

A few things I'm genuinely curious about from anyone who's done this:

  1. Do you enforce semantic tokens with ESLint rules, or rely on code review? We're at the "convention" stage — violations don't fail the build. At what team size or codebase size did it become worth wiring up a linter?

  2. Has anyone automated visual regression for theme switching? Specifically testing both light and dark mode in the same CI run — Chromatic, Percy, Playwright screenshots? What's your setup?

  3. How do you handle the "warm vs neutral" background decision for your product? Pure white felt sterile, warm cream felt right for Kepion — but I'm curious if others have a principled approach or if it's always vibes-driven.

Top comments (0)