← Back to Guides
6 min readBeginner
Share

Adding Dark Mode to a Vibecoded App

A practical walkthrough for adding a proper light/dark theme toggle — CSS variables, system preference detection, and avoiding the flash-of-wrong-theme bug.

Adding Dark Mode to a Vibecoded App

"Add dark mode" is one of the most common follow-up prompts on a finished project — and one of the easiest to get half-right. The AI will usually give you a toggle that flickers on page load, or hardcodes colors in a dozen components instead of one place. Here's the pattern that avoids both.

Step 1: Centralize Your Colors as CSS Variables

Before you touch a toggle, make sure every color in your app traces back to a small set of variables. Hardcoded bg-white or text-black scattered across components is the actual reason dark mode implementations turn into a slog.

:root {
  --bg: #ffffff;
  --surface: #f5f5f5;
  --ink: #111111;
  --ink-muted: #666666;
  --edge: #e5e5e5;
}

:root[data-theme="dark"] {
  --bg: #0a0a0a;
  --surface: #171717;
  --ink: #f5f5f5;
  --ink-muted: #a3a3a3;
  --edge: #262626;
}

body {
  background: var(--bg);
  color: var(--ink);
}

If you're using Tailwind, wire these into your theme instead of writing dark: variants on every element — one variable change updates the whole app instead of hunting down every dark:bg-* class.

Step 2: Toggle a data-theme Attribute, Not a Class Per Component

The toggle only needs to do one thing: flip an attribute on the root element.

function setTheme(theme: "light" | "dark") {
  document.documentElement.setAttribute("data-theme", theme);
  localStorage.setItem("theme", theme);
}

Every component below the root automatically re-themes because it's reading CSS variables, not its own local color logic. This is the difference between a toggle that takes five minutes and one that requires touching every file.

Step 3: Respect System Preference by Default

Don't force light mode on someone whose OS is set to dark. Read prefers-color-scheme as the fallback when the user hasn't chosen explicitly:

function getInitialTheme(): "light" | "dark" {
  const stored = localStorage.getItem("theme");
  if (stored === "light" || stored === "dark") return stored;
  return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}

Step 4: Kill the Flash of Wrong Theme

This is the bug that makes dark mode implementations feel amateurish: the page loads light, then snaps to dark a moment later because your theme logic ran inside a React useEffect — after the first paint.

The fix is to set the attribute before React hydrates, with an inline script in your document head:

<script>
  (function () {
    var stored = localStorage.getItem("theme");
    var theme = stored || (window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
    document.documentElement.setAttribute("data-theme", theme);
  })();
</script>

Placing this as an inline (not deferred, not module) script at the very top of <head> means the attribute is set before any CSS paints, so there's no flash at all. In Next.js, this goes in app/layout.tsx's <head>, not in a client component — client components run after hydration, which is too late.

Step 5: Match the Toggle to Stored State on Mount

If you're persisting the theme to localStorage, make sure the toggle button itself reads that value on mount rather than defaulting to a fixed icon — otherwise the button briefly shows the wrong state even though the page rendered correctly. If your app already has a useLocalStorageState-style hook that handles hydration mismatches, reuse it here instead of writing a second useState + useEffect pair from scratch.

Common Mistakes

Theming with a class instead of a data attribute. class="dark" collides with utility classes and third-party component libraries that expect to own the class list. data-theme="dark" is a dedicated namespace nothing else touches.

Forgetting color-scheme. Native form controls (checkboxes, scrollbars, date pickers) don't reskin themselves just because your CSS variables changed. Add color-scheme: light dark; to :root so the browser re-themes its own UI too.

Only testing the toggle click. Test three states: fresh visitor with no stored preference (should follow system), returning visitor with a stored preference (should honor it, ignoring system), and a hard refresh right after toggling (should not flash).

Prompting for It

If you're asking an AI to add this to an existing project, be explicit about the mechanism — otherwise you'll get a useState toggle with hardcoded Tailwind dark: classes bolted onto every component:

"Add a light/dark theme toggle using a data-theme attribute on <html> and CSS variables for all colors. Persist the choice to localStorage, default to system preference via prefers-color-scheme for new visitors, and set the initial attribute with an inline script in the document head so there's no flash of the wrong theme on load."

That one paragraph is the difference between a toggle that looks finished and one that flickers.

Stay in the flow

Get vibecoding tips, new tool announcements, and guides delivered to your inbox.

No spam, unsubscribe anytime.