Home
VA.
Back to Blog

Six variables, two themes

Keeping a light and a dark theme alive without duplicating a single style rule, using CSS variables and Tailwind 4.

2 min read

The most common way to do dark mode with Tailwind is writing dark: everywhere:

<div class="bg-white text-black dark:bg-neutral-950 dark:text-neutral-50">

It works, and it scales badly. Every new component repeats the decision, and changing the site's background colour turns into a find-and-replace across fifty files.

The inversion

The trick is making components talk about role, not colour. There's no "white" and no "neutral-950" — there's "background".

:root {
  --bg: #ffffff;
  --surface: #fafafa;
  --border: #e6e6e9;
  --fg: #0a0a0a;
  --muted: #6b6b73;
  --accent: #ff5c39;
}
 
.dark {
  --bg: #0a0a0a;
  --surface: #111113;
  --border: #232326;
  --fg: #fafafa;
  --muted: #8a8a93;
  --accent: #ff5c39;
}

Six variables cover an entire site. Background, raised surface, border, text, muted text and one accent. If you need a seventh, it's almost always because a component is asking for an exception it shouldn't get.

Wiring it into Tailwind 4

Tailwind 4 drops tailwind.config.js — configuration is CSS. The @theme block turns each variable into a family of utilities:

@import "tailwindcss";
 
@custom-variant dark (&:where(.dark, .dark *));
 
@theme inline {
  --color-bg: var(--bg);
  --color-surface: var(--surface);
  --color-line: var(--border);
  --color-fg: var(--fg);
  --color-muted: var(--muted);
  --color-accent: var(--accent);
}

After that, bg-bg, text-muted and border-line exist as ordinary classes and already respond to the theme. The component becomes:

<div class="bg-bg text-fg border border-line">

Without a single dark:.

The @custom-variant on the second line is mandatory: by default Tailwind 4 uses prefers-color-scheme, and a manual toggle needs the class strategy.

The flash on first paint

One real problem remains: the server doesn't know which theme the visitor picked. If you render light and the user chose dark, they see a white flash before hydration.

next-themes solves it by injecting a script that runs before first paint, reads localStorage and applies the class to <html>:

<ThemeProvider
  attribute="class"
  defaultTheme="dark"
  enableSystem={false}
  disableTransitionOnChange
>

Two practical consequences. First: <html> needs suppressHydrationWarning, because the script changes the attribute before React compares trees. Second: any component that reads the theme — a button toggling between sun and moon, say — can only render its icon after mounting:

const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
 
return <button>{mounted ? (isDark ? <SunIcon /> : <MoonIcon />) : <span />}</button>;

It looks like overkill for an icon, but it's the same reason: the server has no way to guess right.

The test that matters

A good theming test isn't opening both and finding them pretty. It's this: pick a new colour and change it in exactly one place. If you have to touch more than one file, your components are still talking about colour instead of role.

Tags

  • CSS
  • Tailwind
  • Design System