TL;DR: in Tailwind v4 there are two ways to declare a color token in @theme. One compiles the hex value into your utility classes. The other emits a var(--...) reference that you can override from a wrapper class. Only one of these supports a multi-layer dark-mode cascade. I shipped six surfaces with invisible text because I picked the wrong one.
The setup
I'm running Tailwind v4 on a side project. Dark mode needs to do real work: I have always-light containers (white toast wrappers), always-dark containers (gradient heroes on otherwise-light pages), and components that flip per-theme (everything else). Plus a forced-colors mode for accessibility.
I started with the obvious thing: one @theme block declaring all color tokens, plus a .dark class with overrides. That works for backgrounds. It fell over the moment I tried to override token values from a wrapper class inside a theme.
The two @theme forms
| Form | What Tailwind emits for text-warning
|
Override-able from cascade? |
|---|---|---|
@theme inline { --color-warning: #F59E0B; } |
color: #F59E0B |
No. Hex is baked in. |
@theme { --color-warning: #F59E0B; } |
color: var(--color-warning) |
Yes. Cascade wins. |
This is undocumented in any obvious place. Both forms generate utility classes. Both work for "default" styling. The difference only shows up when a wrapper class tries to override the token.
/* This DOES work — token uses var() */
@theme {
--color-warning: #F59E0B;
}
.surface-context {
--color-warning: #B45309; /* AA-pass amber-700 on amber-50 */
}
/* This does NOT work — token is compiled to hex inside text-warning */
@theme inline {
--color-warning: #F59E0B;
}
.surface-context {
--color-warning: #B45309; /* ignored — text-warning was inlined */
}
The bug I shipped
My /research page had Industry-tier badges using text-warning on an amber-50 background. With text-warning: #F59E0B baked into the utility (amber-500), the contrast ratio was 2.06:1 against #FFFBEB. That's a WCAG AA failure — text barely visible to anyone, invisible to anyone with low vision.
Five other surfaces had the same shape: light surface context, default warning/gold/success token from the inline @theme, no way to nudge the token toward an AA-passing shade in that context.
The fix was a single move: take five tokens out of @theme inline and put them in @theme:
@theme {
--color-warning: #F59E0B; /* default — amber-500 on dark */
--color-gold: #EAB308;
--color-success: #10B981;
--color-danger: #EF4444;
--color-interactive: #6366F1;
}
.surface-context {
--color-warning: #B45309; /* amber-700 on light — AA pass */
--color-gold: #A16207;
--color-success: #047857;
--color-danger: #B91C1C;
--color-interactive: #4338CA;
}
.dark-surface-context {
--color-warning: #FCD34D; /* amber-300 on dark slate */
/* ... */
}
One change. Five surfaces fixed. CI contrast tests turned green across the route × theme matrix.
The 6-layer cascade
For tokens that need to flip across context AND theme, the cascade resolves in this order (later wins):
@theme (default for class)
:root (light-mode defaults)
.dark (page-level dark)
.surface-context (always-light container, even on dark page)
.dark .card-fun (specific component override in dark mode)
.dark-surface-context (always-dark container, even on light page)
The trick is that .surface-context lives ABOVE .dark in specificity by single-class, so a .dark .surface-context ancestor chain still wins via cascade order, not specificity. You don't need !important anywhere. You don't need dark:text-* overrides on individual elements. The wrapper class does the work.
The 17 text tokens that flip via .surface-context vs .dark-surface-context vs tooltip-dark are:
-
--color-text,--color-text-secondary,--color-muted,--color-heading,--color-link,--color-link-hover - 5 overridable accent tokens (above)
- 6 surface-relative tokens (
--color-border, etc.)
Most components never reference theme variables directly. They use the Tailwind utility (text-secondary, border-default) which compiles to color: var(--color-text-secondary). The wrapper class flips the variable. The element doesn't change.
What broke when I tried other shapes
Before this architecture I tried three alternatives. All shipped briefly and reverted.
-
dark:text-*Tailwind forks on individual elements. Worked for one component, became unmaintainable across 200+ surfaces. Every contrast fix required editing every callsite. -
A second
.darkblock inside the cascade. Broke source order. The.darkclass's later declarations clobbered earlier.surface-contextoverrides because they came after in the stylesheet. -
!importanton.surface-contexttoken overrides. Felt wrong. Also broke when a child component needed to push a different value through (no precedence left to play with).
The thing that finally worked is "tokens live in @theme not @theme inline, wrapper classes override variables, utilities consume variables, no dark: patches anywhere."
Verifying it stays correct
I run a Playwright + axe-core matrix that probes 50 routes × 2 themes × 2 viewports = 200 scans on every CI run. If any token regresses (someone moves --color-warning back into @theme inline, or someone hard-codes a hex in a component), the contrast spec fails.
The lint that catches the worst class of regression — hard-coded #hex colors in component files — is a simple regex over the codebase that disallows anything except the design-system-blessed values. Every "I'll just use red here" attempt gets caught at PR time.
Try It
This is the design system powering BingWow, a free real-time multiplayer bingo platform. Light and dark mode are both first-class. Forced-colors works. The contrast matrix is at 100% AA pass on the latest deploy.
- See it light + dark live: bingwow.com
- A theme-heavy gameplay surface: bingwow.com/caller
- For teachers, with AA-pass dark mode: bingwow.com/for/teachers
- Browse 2,000+ cards: bingwow.com/cards
If you've solved the same problem differently in Tailwind v4, I'd love to read it — drop a link in the comments.
Top comments (0)