DEV Community

Forrest Miller
Forrest Miller

Posted on

Tailwind v4 dark mode: the @theme vs @theme inline gotcha that broke my contrast tests

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 */
}
Enter fullscreen mode Exit fullscreen mode

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 */
  /* ... */
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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.

  1. dark:text-* Tailwind forks on individual elements. Worked for one component, became unmaintainable across 200+ surfaces. Every contrast fix required editing every callsite.
  2. A second .dark block inside the cascade. Broke source order. The .dark class's later declarations clobbered earlier .surface-context overrides because they came after in the stylesheet.
  3. !important on .surface-context token 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.

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)