Skip to content
Development5 min read

Dark Mode the Simple Way: One Set of CSS Variables

Dark mode doesn't mean writing your styles twice. Define colors as CSS variables once, flip them under one class, and the whole UI inverts.

Otter
Dark Mode the Simple Way: One Set of CSS Variables

The wrong way to build dark mode is the one most people try first: write your styles, then write a second set of dark styles that override them, and keep the two in sync forever. Every new component now needs designing twice, and the day you forget, something stays stubbornly white in the dark.

There’s a much calmer approach. Define your colors once as CSS variables, then redefine those same variables under a single class. Nothing else in your stylesheet changes. The whole interface flips because every color is pointing at a variable, and you moved what the variable points to.

Here’s the idea in one self-contained card. Toggle it:

Studio

Same markup, different variables

Not one color in this card changed. Only what the variables point to did.

That demo uses a checkbox and :has() so it can stay pure CSS, but the pattern underneath is the one you’d ship. Worth being clear about the difference: the variable-flipping technique below needs nothing but CSS custom properties, which have been safe to use everywhere for close to a decade. :has() is only doing one small job in the demo itself - reacting to the checkbox so this card can toggle without a line of JavaScript on the page:

Browser support · :has()

Chrome105+
Edge105+
Firefox121+
Safari15.4+

Support data from MDN's browser-compat-data project (v8.0.4), sourced from Mozilla. Last checked 2026-07-03.

A real site toggles the .dark class from a click handler instead, the way the rest of this post describes - so even on the rare browser without :has(), the actual technique is untouched. Only this particular zero-JS demo widget depends on it.

Name colors by what they do, not what they are

The first move is to stop thinking in color names and start thinking in roles. Not --off-white and --charcoal, but --surface and --ink. A variable named for its job can hold a light value in one theme and a dark one in the other without its name ever becoming a lie:

:root {
  --surface: #fdfcfa; /* page background */
  --ink: #1a1a1a; /* body text */
  --muted: #6b6b6b; /* secondary text */
  --hairline: #e7e3dd; /* borders, dividers */
  --accent: #c2410c; /* brand color */
}

Then every rule in your stylesheet references the role, never a raw color:

body {
  background: var(--surface);
  color: var(--ink);
}
.card {
  border: 1px solid var(--hairline);
}

Flip the variables under one class

Dark mode is now a single block. You redefine the same variables under a .dark class and write no other dark styles at all:

.dark {
  --surface: #1c1c1c;
  --ink: #f4f4f4;
  --muted: #a3a3a3;
  --hairline: #333;
  --accent: #fb923c; /* nudge the accent brighter so it holds up on dark */
}

Put .dark on the <html> element and every var() in the page resolves to its dark value. Your components didn’t change. They were never tied to a color in the first place. The one thing worth tuning by hand is the accent - a color that pops on a light background often needs lifting a notch to stay vivid on a dark one.

Set the theme before the page paints

There’s one bug everyone hits: the page loads white for a split second, then snaps to dark once your script runs. That flash looks broken. The fix is to decide the theme before the browser paints anything, with a tiny inline script at the very top of <head>, ahead of your styles:

<script>
  const saved = localStorage.theme;
  const prefersDark = matchMedia("(prefers-color-scheme: dark)").matches;
  if (saved === "dark" || (!saved && prefersDark)) {
    document.documentElement.classList.add("dark");
  }
</script>

Because it’s inline and runs immediately, the .dark class is on <html> before the first paint, so there’s no flash. It reads a saved choice from localStorage first, and falls back to the visitor’s system setting when they haven’t picked one. (This site does exactly this - it’s why it never flickers when you load it in the dark.)

Let the toggle do two things

The button that flips the theme has one job in the DOM and one job in storage, so the choice survives a reload:

button.addEventListener("click", () => {
  const isDark = document.documentElement.classList.toggle("dark");
  localStorage.theme = isDark ? "dark" : "light";
});

Why this scales

The real payoff shows up months later. When you add a new section, a new card, a whole new page, you don’t think about dark mode at all - you use your role variables and it just works in both themes, because there was only ever one set of styles. That’s the difference between dark mode being a feature you maintain and a property the system has. It’s the same instinct as leaning on the platform instead of fighting it: less code, fewer ways to drift out of sync.

Back to all articles
Share

Keep Reading

Say hello

Let's make something
you're proud of.

Tell us what you're working on - even a rough idea is plenty. Book a quick call, or send a message and we'll reply like actual humans, usually within a day.

Book a call

A short intro call - no pitch, just a chat about what you're working on.

Prefer email?

[email protected]

Based in

Montenegro

Working with people here, and anywhere the wifi reaches.

Prefer to write?