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.
United States
NORTH AMERICA
Related News
UCP Variant Data: The #1 Reason Agent Checkouts Fail
7h ago
Amazon Employees Are 'Tokenmaxxing' Due To Pressure To Use AI Tools
21h ago
How Braze’s CTO is rethinking engineering for the agentic area
10h ago

Décryptage technique : Comment builder un téléchargeur de vidéos Reddit performant (DASH, HLS & WebAssembly)
17h ago
How AI Reduced Manual Driver Verification by 75% — Operations Case Study. Part 2
4h ago