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.