Skip to content

Tailwind dark mode in v4: the setup I actually use

Tailwind dark mode in v4: the setup I actually use

Confession: I avoided shipping dark mode in side projects for about three years. Not because it’s hard, but because every time I started, I’d lose an afternoon to flickering on first paint, prose that turned into wet concrete, and a toggle button that worked everywhere except the one place users actually opened the site. So I’d put it on the “later” list and ship the light theme.

Tailwind v4 didn’t fix all of that, but it fixed enough that I turn dark mode on by default for new projects now. This is the exact setup I ship, plus the small things I always get wrong if I don’t keep notes.

If you’re still on Tailwind 3, my v4 migration notes cover the bigger config-to-CSS shift. The rest of this assumes you’re already on v4.

What actually changed for dark mode in v4

Tailwind 3 had a JS config block where you set darkMode: 'class' or 'media' and called it a day. v4 moves that into your CSS file, which sounds like a tiny change but matters more than I expected. You set the dark variant as a @custom-variant and target whatever selector you want. The default prefers-color-scheme query still works without configuring anything.

Here’s the v3 way I shipped for years:

// tailwind.config.js  (v3)
module.exports = {
  darkMode: 'class',
  // ...rest of config
}

And here’s the v4 equivalent I use now:

/* app.css  (v4) */
@import "tailwindcss";

@custom-variant dark (&:where(.dark, .dark *));

Two things worth noticing. First, the configuration lives next to the CSS, so the file you import in your component tree is the same file that defines the variant. Less hunting. Second, the :where() wrapper keeps specificity flat, so a dark: utility doesn’t accidentally beat your custom CSS just because the cascade order shifted. I had to debug this exact thing in a project last year and lost a Friday to it.

The official Tailwind v4 dark mode docs cover the variant syntax in more detail if you want to use a data attribute or a parent selector instead of a class.

The toggle I actually ship

For most projects I want the same behavior: respect the OS preference by default, but let users override it and remember their choice. Here’s what I drop in every time. It’s framework-agnostic and runs before React or Vue hydrates, which is the part that prevents the white-flash flicker.

<!-- in <head>, BEFORE your stylesheet -->
<script>
  (function () {
    var saved = localStorage.getItem('theme');
    var systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
    var dark = saved ? saved === 'dark' : systemDark;
    document.documentElement.classList.toggle('dark', dark);
  })();
</script>

The order matters. Inline, in <head>, before your CSS file. If you wait for your framework to hydrate, you get a flash of light mode for 200-400ms on every page load, and users will absolutely tell you about it.

The toggle button itself is boring. Three lines:

function toggleTheme() {
  const isDark = document.documentElement.classList.toggle('dark');
  localStorage.setItem('theme', isDark ? 'dark' : 'light');
}

I know the modern guidance says use data-theme attributes for multi-theme apps, and that’s fine. For two themes, classList.toggle is one fewer thing to think about.

One thing I started adding last year: set the color-scheme CSS property on the root. It tells the browser to use dark scrollbars and dark form controls, which you’d otherwise have to override by hand.

:root { color-scheme: light; }
.dark { color-scheme: dark; }

No more white scrollbar shouting at you in the corner of a dark page.

The thing about prose I keep forgetting

This is the bug I introduce in every project. I set up dark mode for buttons and cards and headings, ship it, then open a blog post and the body text is unreadable because @tailwindcss/typography’s prose class still uses light-mode colors.

The fix is one line in the markup:

<article class="prose dark:prose-invert">

That’s it. prose-invert flips the whole typography stack to dark-friendly contrast ratios. I forget this in every greenfield project and then wonder why my markdown looks broken at midnight. Sticky-note this one if you’re reading this on a Friday.

What about images and inline SVG

Images are the part I usually get wrong on the second pass, after I’ve convinced myself dark mode is done.

For logos and inline SVG icons, the cleanest fix is to use currentColor for the fill or stroke, then drive the color with text utilities:

<svg className="text-zinc-900 dark:text-zinc-100" fill="currentColor">
  <path d="..." />
</svg>

For raster images that include white backgrounds (screenshots, charts, anything from a designer), I use a slight invert filter only in dark mode:

<img class="dark:brightness-90 dark:contrast-95" src="/chart.png" />

I do not blanket-invert images with filter: invert(1). That ruins photos. The 90% brightness trick takes the harsh white edge off without killing the colors. For real charts, I’ll usually export a separate dark-mode PNG and swap it with <picture> and media="(prefers-color-scheme: dark)".

When dark mode “doesn’t work”

Three things have caused most of my dark mode bugs. In rough order of how often they bite me:

The class isn’t on <html>. Some component libraries add dark to a wrapper div. Tailwind’s default variant looks for an ancestor, so a wrapper works in theory, but it breaks if the wrapper is a sibling of your modal or popover portal. Put the class on documentElement and stop second-guessing it.

You’re checking prefers-color-scheme in JS but not in CSS. If you want the OS preference to work even when JS is off, you’d need both a media query and your variant. The simplest path is to keep the inline script above, which puts the class on <html> immediately. Don’t try to make @media (prefers-color-scheme: dark) and a dark: variant coexist. Pick one source of truth (the class) and let the script set it from the media query.

Your colors are technically valid but visually wrong. Pure white on pure black is brutal at night. The Material Design team published actual contrast guidance for dark themes that’s worth skimming. The short version: your darkest background should be a dark gray like #121212 or Tailwind’s zinc-950, not #000. Your text should be off-white, not #fff. Pure black plus pure white reads as “we tested this for ten minutes.”

A five-minute audit I run before shipping

Every time I add dark mode I run the same four checks. They take about five minutes and catch the embarrassing stuff before users do.

First, hard-refresh the page in OS dark mode. No flash of white. If there’s a flash, your inline script is in the wrong place or it’s running too late.

Second, toggle the theme three times in a row without reloading. Make sure scrollbars, modals, and dropdowns all flip. Native <select> elements need color-scheme set on the root or they stay light forever, which is what the CSS snippet earlier fixes.

Third, open every page that renders user content. Markdown, HTML email previews, embedded code blocks. These are the ones that get missed because they’re rendered through helpers that bypass your component styles.

Fourth, screenshot both modes on a real iPhone or Android. Not a browser devtools simulation. Phones have OLED black handling that desktop monitors don’t, and your bg-zinc-950 will look different on glass than it does on your laptop.

That’s the whole setup. Tailwind v4 made the actual configuration boring, which is exactly what I want from a styling tool. Most of the work in dark mode isn’t writing classes; it’s catching the four places you forgot. The audit catches them faster than your users will.

If you want to see this kind of styling logic in a real shipped project, there’s a couple of examples over in my work. And if you ever figure out a better way to handle screenshots in dark mode, please tell me, because I’m still not happy with mine.