# 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.

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

```css
@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:

```css
/* 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:

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

which generates:

```css
.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

```css
/* 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:

```bash
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.

---

Agentic Boilerplate: A Next.js repo your agent already knows. Free during launch, then $99 once.

- Site map for agents: https://agenticboilerplate.com/llms.txt
- Public API: https://agenticboilerplate.com/openapi.json
- Contact: agenticstudio@gmail.com
