Skip to main content
ASoc
Tutorial

Tailwind v4 Dark Mode: We Wrote 480 Variants and Shipped No Toggle

One @custom-variant line replaces darkMode: 'class'. Then you choose: dark: at every call site, or tokens under .dark. We picked the first 480 times — and found nothing can turn it on.

The ASoc Team11 min read

Tailwind v4 has no darkMode: "class" option, because it has no tailwind.config.js. Class-based dark mode is now one line of CSS — @custom-variant dark (&:where(.dark, .dark *)) — and after that you choose between two architectures: write dark: at every call site, or redefine your tokens under .dark. This codebase picked the first and wrote it 480 times. Here is the bill.

The one line that replaces the config option

/* src/app/globals.css */
@import "tailwindcss";

/* Class-based dark mode (toggled by adding `dark` to <html>) */
@custom-variant dark (&:where(.dark, .dark *));

That is the whole setup. Without it, dark: is a media-query variant that follows the OS and cannot be toggled. With it, dark:bg-gray-900 compiles to a rule that applies when the element is — or is inside — something carrying .dark.

The :where() is not decoration, and it is the detail most migration guides drop. :where() has zero specificity, so dark:bg-gray-800 does not outrank bg-white; it wins on source order like any other Tailwind utility. Write it as &:is(.dark *) instead and every dark utility silently becomes more specific than its light counterpart, which works until the day you need to override one and cannot.

One practical warning that costs an hour the first time: Tailwind v4 with Turbopack does not hot-reload @theme changes. Add a colour token, watch bg-brand-500 produce nothing, and start debugging your CSS when the fix is restarting npm run dev. It is written into this repo's CLAUDE.md because it caught us.

The two architectures

Call-site variantsToken swap
What you writeclassName="bg-white dark:bg-gray-800"className="bg-surface"
Where dark livesEvery componentOne CSS block
Adding a componentMust remember dark: on every colourFree — it inherits
Reviewing a diffReviewer must spot a missing dark:Nothing to spot
Third theme (high contrast, per-tenant)Rewrite every call siteAnother token block
Fine-grained deviationTrivial — it is per elementNeeds a new token
Failure modeWhite text on white, in one component nobody openedA token nobody defined

Both are legitimate. The call-site approach is what the Tailwind docs show, it is what almost every template ships, and it is what this site does. The token approach is what we use for a different problem in the same file — multi-tenant theming with Tailwind v4 walks through swapping brand tokens per tenant, which is exactly the mechanism that would also serve dark mode.

What 480 variants look like in practice

Counted across src/components and src/app in this repository:

MeasureValue
dark: utility occurrences480
.tsx files containing at least one49
Total .tsx files in those trees124
Atoms carrying a dark: variant1 of 6

The six most-used variants, which tell you the palette by themselves:

VariantUses
dark:text-white117
dark:text-gray-40073
dark:border-gray-70071
dark:bg-gray-80055
dark:text-primary-40041
dark:bg-gray-90020

Those six account for well over half of all 480. That is the tell: six repeated pairings, written out 377 times, are six tokens that were never created. Every one of bg-white dark:bg-gray-800 could have been bg-surface with two definitions in one place.

The house rule that grew out of the sprawl now lives in CLAUDE.md, and it is a rule you only need because of the architecture:

New styled elements need dark: variants (containers dark:bg-gray-900, elevated dark:bg-gray-800, borders dark:border-gray-700); the shared Button atom has no dark variants — use the established call-site className workarounds.

A convention documented in prose, enforced by code review, describing a thing a token would have made automatic.

The atom that proves the cost

Button is the most reused component in the codebase, and it is the one with no dark support at all:

// src/components/atoms/Button.tsx
const variants: Record<ButtonVariant, string> = {
  primary: "bg-primary text-white hover:bg-primary-600",
  outline:
    "border border-stroke-tertiary bg-white text-text-color hover:bg-gray-50 hover:text-gray-800",
  dark: "bg-gray-900 text-white hover:bg-gray-800",
};

Note the trap in the naming: variant="dark" is a visual style — a dark-coloured button on a light page — and has nothing to do with dark mode. The outline variant is the one that actually breaks in dark mode, because bg-white text-text-color is hardcoded light. Every call site that needs an outline button on a dark background patches it with a className override, which is the workaround the house rule is describing.

Under a token architecture, outline would read border border-default bg-surface text-body and there would be nothing to patch. This is the clearest case in the codebase for why the choice matters: the call-site approach taxes exactly the components you reuse the most.

The tokens that are single-valued

Why not just swap the tokens now? Because of what is in @theme:

@theme {
  --color-text-color: #344054;
  --color-text-color-secondary: #667085;
  --color-title-color: #1d2939;
  --color-stroke: #e4e7ec;
  --color-stroke-secondary: #f2f4f7;
  --color-stroke-tertiary: #d0d5dd;
}

These are semantic names — text-color, title-color, stroke — which is exactly the shape a token swap wants. But each holds one value, chosen for a light background, and the only theme-aware rule in the whole stylesheet is the body:

@layer base {
  body {
    @apply bg-white font-sans text-base font-normal text-gray-700;
  }
  .dark body {
    @apply bg-gray-900 text-gray-300;
  }
}

So the semantic layer looks like a theming system and is not one. Redefining those six under .dark would flip a large fraction of the site in one edit — and would also collide head-on with the 480 explicit variants already overriding them, which is why it is a migration rather than a patch. Worth knowing before you inherit a codebase that looks like it has themed tokens.

The finding: nothing can turn it on

Writing this post turned up something we did not expect. The theme is read exactly once, in a blocking script in the root layout:

// src/app/layout.tsx
<script
  dangerouslySetInnerHTML={{
    __html: `try{if(localStorage.getItem('theme')==='dark'){document.documentElement.classList.add('dark')}}catch(e){}`,
  }}
/>

That script is correct, and it is correct for the reason dark mode without the flash of wrong theme sets out in full: it is synchronous, in <head>, before first paint, wrapped in try/catch for browsers with storage disabled, and paired with suppressHydrationWarning on <html>.

It is also the only place in the repository that touches the theme. A grep for classList, documentElement, or the theme key across all of src/ returns that one line and nothing else. There is no ThemeToggle component. Nothing ever calls localStorage.setItem("theme", "dark").

Which means: 480 dark variants, 49 files, a documented house rule, a correct no-flash bootstrap — and no visitor can reach any of it. Dark mode renders only for someone who opens devtools and sets the key by hand. This is not a bug in any single file; every piece is individually right. It is what happens when a feature is built bottom-up and the ten-line control at the top is the piece nobody was assigned.

We are recording it rather than quietly shipping a toggle, because where a theme control belongs in the header is a design decision and this is a post, not a redesign. It goes on the backlog with the reasoning intact. The transferable lesson: if a feature has no entry point, no amount of coverage underneath it counts. Grep your own repo for the write side of every preference you read.

If you are adding the toggle

For completeness, the missing half is small — and the ordering is what matters:

"use client";
export function ThemeToggle() {
  const [dark, setDark] = useState(false);
  useEffect(() => setDark(document.documentElement.classList.contains("dark")), []);
  return (
    <button
      type="button"
      aria-pressed={dark}
      onClick={() => {
        const next = !dark;
        document.documentElement.classList.toggle("dark", next);
        try {
          localStorage.setItem("theme", next ? "dark" : "light");
        } catch {}
        setDark(next);
      }}
    >
      {dark ? "Light mode" : "Dark mode"}
    </button>
  );
}

Read the current state from the DOM in an effect, not from localStorage during render — the blocking script has already applied it, and reading storage during render is the hydration mismatch we take apart in persisting UI state without a hydration mismatch. aria-pressed rather than role="switch" keeps it a plain toggle button, and writing "light" explicitly matters: it is how you distinguish "chose light" from "never chose", which is the difference between honouring prefers-color-scheme and ignoring it.

Common mistakes

MistakeSymptomFix
darkMode: "class" in a config fileNo effect — v4 has no config file@custom-variant dark (&:where(.dark, .dark *))
&:is(.dark *) instead of :where()Dark utilities outrank light ones by specificityKeep the zero-specificity :where()
Editing @theme and not restarting devNew utilities silently generate nothingRestart npm run dev — Turbopack does not reload @theme
Toggling the class on <body>Utilities on <html> and portals miss itToggle on document.documentElement
No suppressHydrationWarning on <html>React warns about the class the script addedAdd it — the mismatch is intentional
Reading localStorage during renderHydration mismatch, or a flashRead in an effect; let the blocking script paint
Storing only "dark"Cannot tell "chose light" from "never chose"Store "light" explicitly too
Six variant pairs repeated 377 timesA convention in a docs file instead of a tokenSemantic tokens redefined under .dark
A shared atom with no dark variantsEvery call site patches it with classNameFix the atom, or tokenize it
Reading a preference nothing writesA whole feature with no entry pointGrep for the write side

Frequently asked questions

Is @custom-variant the only way in Tailwind v4? It is the way to get toggleable dark mode. The built-in dark: variant still works out of the box against prefers-color-scheme; @custom-variant redefines it to key off a class instead. You can also point it at a data attribute — @custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *)) — which is handier if you plan a third theme later.

Call-site variants or token swap — which should I start with? Token swap, if you are starting today and expect more than two themes or a design system. Call-site variants are fine for a fixed light/dark pair on a small surface, and they are what most templates ship, so you will meet them. The number that should decide it is the one above: when six variant pairs account for most of your dark: usage, those are tokens.

Does dark mode cost bundle size? Barely. Every dark: utility is one extra CSS rule, and Tailwind only generates the ones you use. 480 occurrences collapse to far fewer unique rules. The cost of the call-site approach is maintenance, not bytes.

How do I keep contrast correct in both themes? Check both, with a tool, on real pages. Our own audit found a tag chip missing AA by 0.01 — a gap no reviewer sees by eye, on a component that looked fine. The wider case for measuring rather than assuming is in why a Lighthouse accessibility score of 100 is not WCAG conformance.

Templates with dark mode already wired

ASoc Vertex and ASoc Crest are admin dashboards with a full component library — tables, charts, forms — themed for both modes, which is where the per-call-site cost is highest and most worth having done for you. ASoc Admin is the flagship: 13 dashboards across 135+ pages in React, Next.js, Vue and Angular editions, with the workspace suite (inbox, chat, calendar, kanban, invoicing) themed throughout.

Browse the sets: React admin templates, Next.js admin templates, Tailwind admin templates. For the theme-token mechanism used deliberately rather than by accident, read multi-tenant theming with Tailwind CSS v4; for what changed in v4 generally, the migration guide.

Keep reading

Tutorial9 min read

A Tailwind Data Table Audit: One Real Table, Two Real Defects

The only <table> in this codebase, read cell by cell: what it gets right, the missing caption and text-free checkmarks it shipped with, and the fix.

Read more
Tutorial9 min read

Tailwind Design Tokens: 41 Declared, 22 Values That Went Around Them

41 tokens across three of Tailwind v4's 19 namespaces, and 161 utilities that spell a value literally instead. 22 of those literals were already a token in the same stylesheet.

Read more