Jun 3, 2026

Updated Oct 2, 2026

9 min read

AI-Ready Design Systems (4/7)

Tailwind v4 semantic design tokens + WCAG contrast for Next.js (2026)

Francois Brill

Francois Brill

Designer + Builder

Tailwind v4 semantic design tokens + WCAG contrast for Next.js (2026)

Tailwind v4 semantic design tokens for Next.js are two files, and the contrast lives in the names. tokens.css holds the primitives (yellow-400, gray-500) as :root variables. An @theme block maps those onto roles (foreground, background, accent) so text-foreground and bg-accent share one source of truth. Treat each foreground/background pair as accessible color contrast: 4.5:1 for body text, 3:1 for large text. Import tokens.css from app/layout.tsx before globals.css. Nuxt lists the same file first in css[].

A raw @import of tokens.css beside @import "tailwindcss" is the dead end. Tailwind special-cases its own import, so the second file often never reaches the served CSS. The build succeeds. The page looks fine. Curl the stylesheet and grep for --color-yellow-400. Nothing means the variables never left the pipeline. Load the file from the layout, then curl again.

This is Article 4 of AI-Ready Design Systems. How we structure design systems for AI is why a fresh session needs the split. The five-tier folder is where those token files sit. Names and usage rules live in DESIGN.md. Component specs are where each spec names the tokens it consumes. When the design system and the code disagree is the drift handoff when a live gray misses AA.

Primitive vs semantic tokens

Primitives are the raw scale. yellow-400, gray-500, a hex that lives exactly once in tokens.css. You don't reach for them in components.

Semantics are roles. accent, foreground, foreground-muted, surface, background. The @theme block maps each role onto a primitive so bg-accent and text-foreground resolve as Tailwind utilities without copying a hex into a component file.

That mapping is where contrast gets codified. A semantic pair is a contrast contract: foreground on background should clear about 4.5:1 for body text, 3:1 for large text (18pt or 14pt bold). Muted foreground is for captions, timestamps, and metadata, not sustained reading. The usage rule belongs with the token, not only the hex.

Tokens as a contrast contract

Once foreground-muted is a legal Tailwind utility, someone will put it on body copy. A Figma comment will not stop that. The contract is the pair (foreground on background for reading, muted on captions only) plus the usage rule next to the token.

“
Tokens without usage rules are a prettier way to ship the same contrast bug.

This article does not re-run the luminance math. Article 6 is the reconciliation: two definitions of gray-500, a live value that scored 3.92:1 against white, and why foreground-muted on body paragraphs was the real miss. Here we wire the contract so there's one place to fix it when it's wrong.

What we wanted

Two consumption modes, together:

  • var(--color-accent) available everywhere, including inside <style> blocks and inline styles
  • Tailwind utilities like bg-accent, text-foreground-muted, shadow-card generated from the same values

One source of truth. Non-destructive, meaning no rewriting the existing config. The tokens live in app/design-system/tokens.css and flow into the @theme block in the Tailwind entry. No duplication of hex codes. No manual sync between files.

That's the goal. We started going wrong here.

The first attempt (the dead end)

The obvious move: import tokens.css from inside the Tailwind entry. It felt right. @theme would reference --color-yellow-400 from that file, Tailwind would process it, utilities would generate. The served CSS had none of it.

/* app/assets/css/tailwind.css – doesn't work */
@import "tailwindcss";
@import "../../design-system/tokens.css"; /* tokens don't appear in served CSS */
 
@theme {
  --color-accent: var(--color-yellow-400);
  --color-foreground: var(--color-gray-900);
  --color-background: var(--color-gray-200);
  --color-foreground-muted: var(--color-gray-500);
  --spacing-section-y: 5rem;
  --shadow-card: 0 1px 3px rgb(0 0 0 / 0.1);
  --radius-card: 0.5rem;
}

Why we caught it

I was debugging a component that wasn't picking up the right yellow. DevTools showed the utility class in the DOM, but no CSS declaration. I curled the served file:

curl http://localhost:3000/_nuxt/assets/css/tailwind.css | grep -- "--color-yellow-400"

Nothing. No output. The token didn't exist in the served CSS.

I also checked for the :root block itself:

curl http://localhost:3000/_nuxt/assets/css/tailwind.css | grep ":root"

Still nothing. tokens.css had not been inlined.

“
Curl the served CSS. Don't trust the dev server's appearance. A missing token is silent until something breaks.

Tailwind v4 handles @import "tailwindcss" as a special case. Arbitrary @imports pointing at other CSS files don't get inlined in the same pass. It is not a general-purpose CSS bundler. Everything else is the framework's job.

The fix

Let the framework handle tokens.css. In Nuxt, that means the css array in nuxt.config.ts. Load tokens.css before the Tailwind entry so the :root block is globally available before any component renders.

// nuxt.config.ts
export default defineNuxtConfig({
  css: [
    'app/design-system/tokens.css',   // ships :root vars globally
    'app/assets/css/tailwind.css',    // Tailwind entry with @theme
  ],
})

Then strip the broken @import from the Tailwind entry. Keep only the Tailwind directive and the @theme block:

/* app/assets/css/tailwind.css */
@import "tailwindcss";
 
@theme {
  --color-accent: var(--color-yellow-400);
  --color-foreground: var(--color-gray-900);
  --color-background: var(--color-gray-200);
  --color-foreground-muted: var(--color-gray-500);
  --spacing-section-y: 5rem;
  --shadow-card: 0 1px 3px rgb(0 0 0 / 0.1);
  --radius-card: 0.5rem;
}

tokens.css stays clean: raw CSS custom properties, nothing else.

/* app/design-system/tokens.css */
:root {
  --color-yellow-400: #F7B200;
  --color-gray-200: #E5E7EB;
  --color-gray-500: #6B7280;
  --color-gray-900: #111827;
}

--color-foreground should clear AA against --color-background for body text. --color-foreground-muted can sit lower only if the usage rule says captions, not paragraphs.

Re-verify with the same curl command:

curl http://localhost:3000/_nuxt/assets/css/tailwind.css | grep -- "--color-yellow-400"
# --color-yellow-400: #F7B200;

The token appears. The :root block is present. bg-accent resolves to var(--color-accent), which in turn resolves to #F7B200.

Why both layers

Two files. Two jobs.

tokens.css ships the :root block. It makes raw CSS variables available everywhere: third-party components, Vue <style> blocks, inline styles, anything outside Tailwind's reach.

The @theme block in the Tailwind entry maps those raw values to semantic names and generates the utility classes. bg-accent works because --color-accent is registered as a Tailwind theme variable pointing at var(--color-yellow-400). text-foreground works the same way against background.

“
The two-layer setup is intentional. One layer ships the variables. The other generates the utilities. They're different jobs done by different tools.

There's apparent duplication: --color-yellow-400 appears in tokens.css and is referenced in @theme. That's fine. The hex lives in exactly one place. @theme holds semantic mappings, not raw values.

Wiring the same tokens in Next.js

The pattern is the same. The mechanism differs.

Next.js has no css array in config. Instead, you import files directly in app/layout.tsx. Load tokens.css first, then globals.css (your Tailwind entry). Import order determines load order.

// app/layout.tsx
import '../design-system/tokens.css'
import './globals.css'
 
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  )
}

globals.css mirrors the Tailwind entry exactly:

/* app/globals.css */
@import "tailwindcss";
 
@theme {
  --color-accent: var(--color-yellow-400);
  --color-foreground: var(--color-gray-900);
  --color-background: var(--color-gray-200);
  --color-foreground-muted: var(--color-gray-500);
  --spacing-section-y: 5rem;
  --shadow-card: 0 1px 3px rgb(0 0 0 / 0.1);
  --radius-card: 0.5rem;
}

The mental model is identical: tokens.css lands first, supplies :root, and @theme can reference those values. A Button at app/design-system/components/Button.tsx can use className="bg-accent text-foreground" and both the utility and var(--color-accent) resolve. Pair text-foreground with a surface that clears AA. Don't reach for text-foreground-muted on the label unless you've measured that pair.

Auto-importing design system components

In Nuxt, components.dirs with a Ds prefix turns app/design-system/components/Card.vue into <DsCard /> with no per-page imports:

components: {
  dirs: [{ path: 'app/design-system/components', prefix: 'Ds' }],
}

Next.js has no equivalent. Export from app/design-system/index.ts and import { DsCard, DsButton } from '@/design-system'. One barrel, stable paths.

Verifying it works

Two curl commands confirm both layers are live in Nuxt:

# Confirm primitive tokens are in served CSS
curl http://localhost:3000/_nuxt/assets/css/tailwind.css | grep -- "--color-yellow-400"
# Expected: --color-yellow-400: #F7B200;
 
# Confirm Tailwind utilities are generated from semantic tokens
curl http://localhost:3000/_nuxt/assets/css/tailwind.css | grep "bg-accent"
# Expected: .bg-accent { background-color: var(--color-accent) }

If the first grep returns nothing, tokens.css is not loading. Check css[] order and the path. If the second returns nothing, @theme isn't being processed. Confirm the file starts with @import "tailwindcss" and the semantic names match your markup.

For Next.js, curl the rendered HTML for the stylesheet link, then inspect the built CSS:

curl -s http://localhost:3000/ | grep 'tokens.css'

After next build, inspect .next/static/css/. The generated stylesheet should contain both the :root block from tokens.css and the .bg-accent / .text-foreground rules from the processed @theme block.

Key insights

Tailwind v4 doesn't inline arbitrary @imports. Use the framework's CSS loading mechanism instead.

Two layers are intentional. tokens.css ships :root vars. @theme generates utilities from semantic names. Primitive hex lives once.

Curl the served CSS. A component that looks fine can still be missing tokens. Fallbacks to inherit or transparent hide the gap.

Nuxt uses css: []. Next.js uses import order in layout.tsx. Same mental model, different mechanism.

Semantic tokens are a contrast contract. foreground on background is an AA pair. Usage rules travel with the token. The longer lesson is Article 6.

Trials and errors

After the @import inside tailwind.css failed, I tried postcss-import. Resolve the file at the PostCSS level before Tailwind sees it. It worked inconsistently: plugin order, package version, and whether Nuxt's Vite plugin ran before or after PostCSS all changed the result.

I also misread @theme inline. That keyword affects how variables are output, not how external files are imported. It wasn't relevant.

The fix was to stop asking Tailwind to load the file. Nuxt's css[] and Next's layout.tsx imports already do that job. Tailwind generates utilities from @theme. Keep the jobs separate. That's the version we use on every project now.

Where this sits in the series

Tokens are the runtime foundation. Article 5 uses them in component anatomy: each spec declares which tokens it consumes. Article 3 is where to start if the token layer doesn't exist yet. Article 6 is where to go when the design file and the code disagree, including the WCAG miss this contract is meant to catch.

The series hub has the full sequence. DESIGN.md carries the vocabulary. Reverse-specs is the path when the system has to come from code that already ships. Next in the series is Article 5.

Frequently asked questions

How do you map primitive + semantic tokens into Tailwind v4 for Next.js?
Primitives are the raw scale: one hex each (gray-500, yellow-400) in tokens.css. Import that file from app/layout.tsx before globals.css so Next.js serves the :root variables. Semantic tokens are the roles. Map foreground, background, and accent in an @theme block in globals.css, and bg-accent plus text-foreground share those variables. On Nuxt, list tokens.css first in the css[] array in nuxt.config.ts. Those files sit in the five-tier folder.
Why does @import of tokens.css fail, and how do you verify?
Tailwind v4 special-cases @import "tailwindcss". A second @import of tokens.css in that same entry often never reaches the served CSS. The build still succeeds, and the page can look fine. Load tokens.css from the framework instead (the Next.js layout import, or Nuxt css[]). Curl the served stylesheet and grep for --color-yellow-400. No match means the file never left the Tailwind pipeline.
How do semantic tokens keep contrast pairs WCAG-minded?
The name is the contract, not a prettier hex. Set foreground on background once and write the AA bar beside the token: 4.5:1 for body text, 3:1 for large text. Muted foreground stays for captions, timestamps, and metadata, not paragraphs. Every component that uses the pair inherits that contract. When the design system and the code disagree is the handoff, including a live gray that scored 3.92:1 against white.
Where does this sit versus the series hub, DESIGN.md, and component specs?
This page is the runtime wiring: tokens.css plus @theme for Next.js, and the Nuxt css[] equivalent. The series hub is the full path. How we structure design systems for AI is why a stateless session needs that split. The five-tier structure is the folder. DESIGN.md holds the names and the usage rules. The CSS that ships is this wiring. Component specs are where each spec names the tokens it consumes.

About the author

Francois Brill

Designer + Builder, Clearly Design, Inc.

Francois has been designing and shipping software products since 2002, with a focus on the messy middle between design and the codebases that consume them. He runs Clearly Design, a product design subscription studio for SaaS founders, and writes about how design systems hold up under AI-assisted development.

X / TwitterClearly DesignGitHubLinkedIn

Want your tokens wired once and readable everywhere?

Clearly Design sets up design tokens that resolve as both CSS variables and Tailwind utilities, wired into your stack and verified, as part of a product design subscription.