惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

D
Docker
B
Blog RSS Feed
Microsoft Security Blog
Microsoft Security Blog
Y
Y Combinator Blog
N
Netflix TechBlog - Medium
M
MIT News - Artificial intelligence
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
B
Blog
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
C
Check Point Blog
The GitHub Blog
The GitHub Blog
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
P
Proofpoint News Feed
Martin Fowler
Martin Fowler
大猫的无限游戏
大猫的无限游戏
GbyAI
GbyAI
博客园_首页
A
About on SuperTechFans
Blog — PlanetScale
Blog — PlanetScale
人人都是产品经理
人人都是产品经理
T
Tailwind CSS Blog
aimingoo的专栏
aimingoo的专栏
T
The Blog of Author Tim Ferriss
The Cloudflare Blog

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant
Tailwind v4 dark mode: the @theme vs @theme inline gotcha...
Forrest Mill · 2026-05-12 · via DEV Community

Forrest Miller

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.