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.