Skip to content

Tailwind v4: your dark mode does nothing

Adding a .dark block that overrides your colour variables changes nothing if the utilities were generated with @theme instead of @theme inline. Here is the difference and the fix.

Terminal4 min readships at docs/solutions/terminal/tailwind-v4-dark-mode-does-nothing.md

Tags: tailwind · tailwind-v4 · dark-mode · css · design-tokens

You wire up dark mode the way every guide describes. A class on <html>, a block of overrides, done:

@theme {
  --color-canvas: #ffffff;
  --color-ink: #09090b;
}

.dark {
  --color-canvas: #09090b;
  --color-ink: #fafafa;
}

You add class="dark" in dev tools. The page does not change. The variables in the inspector do change (--color-canvas is #09090b on the <html> element) but the body is still white.

Why it happens

@theme does two things that look like one. It declares a custom property, and it registers a theme value that Tailwind uses when it generates utilities. By default, the generated rule contains the value, not the variable:

/* what @theme generates */
.bg-canvas {
  background-color: #ffffff;
}

The hex was resolved at build time. Your .dark block changes a custom property that the rule no longer consults, so nothing moves. The variable in the inspector is real; it is simply not what paints the pixel.

This is a deliberate default: inlining is smaller and faster when a value never changes at runtime, which is true of most theme values, like a spacing scale.

The fix: @theme inline

@theme inline tells Tailwind to keep the var() in the generated rule:

@theme inline {
  --color-canvas: var(--canvas);
  --color-ink: var(--ink);
}

which generates:

.bg-canvas {
  background-color: var(--canvas);
}

Now the utility resolves the variable at paint time, on the element it is applied to, so any ancestor that redefines --canvas re-themes everything inside it.

Note what changed as well as the keyword: the theme value is now a reference to a separate variable that holds the actual colour. That indirection is the point. --color-canvas is the stable name Tailwind generates from; --canvas is the value you swap per mode.

The full shape

/* 1. the palette, as plain custom properties: swapped per mode */
:root {
  color-scheme: light;
  --canvas: #ffffff;
  --ink: #09090b;
  --border: #e4e4e7;
}

:root.dark,
.dark {
  color-scheme: dark;
  --canvas: #09090b;
  --ink: #fafafa;
  --border: #27272a;
}

/* 2. the theme, referencing them */
@theme inline {
  --color-canvas: var(--canvas);
  --color-ink: var(--ink);
  --color-hairline: var(--border);
}

/* 3. everything else, which never changes with the mode, stays in @theme */
@theme {
  --radius-lg: 0.5rem;
  --spacing-md: 1rem;
  --text-body-sm: 0.875rem;
}

Three details worth understanding rather than copying.

Only colours need inline. A radius or a spacing step is the same in both modes, so inlining the value is strictly better: smaller CSS, one less indirection.

Put the palette blocks outside @layer. Unlayered declarations win over anything inside a cascade layer, which saves you from fighting a base layer that sets color-scheme or a background somewhere else in the stylesheet.

Set color-scheme. It is what makes form controls, scrollbars and the default canvas behind your page follow the theme. Without it you get a light scrollbar on a dark page, and a white flash between paints.

Checking which one you have

The compiled stylesheet answers immediately:

grep -A1 "\.bg-canvas" .next/static/css/*.css

A hex in the rule means the value was inlined at build time and dark mode will not reach it. A var(--canvas) means you are set.

The other quick test: open dev tools, put class="dark" on <html>, and watch the computed background of <body>. If the custom property changes but the computed colour does not, this is your bug.

The same trap, other namespaces

Anything you intend to change at runtime has to be a reference, not a value:

  • Shadows that need a higher alpha in dark mode: @theme inline { --shadow-soft: var(--shadow-soft-value); }
  • A per-tenant brand colour injected as a variable on a wrapper element.
  • A font swap driven by a class, though those rarely need it.

Anything that is genuinely constant (spacing, radii, type scale, breakpoints) belongs in a plain @theme, where inlining makes the output smaller.

While you are here: two dark-mode bugs that survive this fix

Hard-coded colours. bg-white, text-black, bg-zinc-100 and any hex in a component are fixed values; no amount of variable swapping reaches them. They are invisible to the author, whose machine is set to light, and they are the single most common source of "dark mode is broken" reports. A lint that fails the build on a palette class or a hex literal is worth the afternoon it takes to add.

Pastel semantic tints. A success badge that is dark green text on a pale green back reads well on white. Lighten the same pair for dark mode and it glows. The dark values want to go the other way: very dark tinted back, light text.