Jun 3, 2026

10 min read

AI-Ready Design Systems (4/7)

Wiring design tokens through Tailwind v4 (Nuxt and Next)

Francois Brill

Francois Brill

Designer + Builder

Wiring design tokens through Tailwind v4 (Nuxt and Next)
TL;DR

For engineers: the two-layer Tailwind v4 wiring we settled on (tokens.css via the framework + @theme in the Tailwind entry), why a direct @import fails, how to verify the served CSS with curl. Both Nuxt and Next variants below.

For founders: this is the kind of debugging discipline our team brings when we wire tokens into your stack. The detail is engineer-shaped, but the principle is "your tokens should resolve as both CSS variables and Tailwind utilities, from one source of truth." If you'd rather read the methodology, Article 3 covers the audit step we run before any code gets written.

I thought tokens.css imported from inside tailwind.css would inline. It didn't.

The build ran. The dev server booted. Pages looked fine. I spent an hour convinced it was working before I curled the served CSS and found nothing. No primitive tokens. No :root block. The @import had silently done nothing.

This is the wiring we've landed on across every Nuxt + Tailwind v4 project since. It's a two-layer setup: one file ships CSS variables globally, the other generates Tailwind utilities from those same values. One source of truth. No surprises.

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.

/* 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-muted: var(--color-gray-500);
  --spacing-section-y: 5rem;
  --shadow-card: 0 1px 3px rgb(0 0 0 / 0.1);
  --radius-card: 0.5rem;
}

This feels right. The @theme block references --color-yellow-400, which lives in tokens.css. Tailwind processes the file. Utilities get generated.

Except the import doesn't land.

Why we caught it

I was debugging a component that wasn't picking up the right yellow. The background color was missing entirely. I opened DevTools, saw the utility class in the DOM, but no corresponding CSS declaration. That's when I curled the served file directly:

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. It processes that directive and generates utilities from the @theme block. Arbitrary @import statements pointing to other CSS files don't get inlined in the same pass. Tailwind processes what it understands and passes the rest through, without guaranteeing the import resolves in the served output.

Tailwind v4 is not a general-purpose CSS bundler. It processes its own layer. 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-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;
}

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 tailwind.css 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).

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 the @theme block. That's fine. The hex value #F7B200 lives in exactly one place. The @theme block holds semantic mappings, not raw values. A future build step can generate the @theme block from tokens.css automatically, but the two-file structure stays either way.

The React/Next equivalent

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-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 the :root block, and the @theme block can reference those values safely.

Example: a Button component at app/design-system/components/Button.tsx uses className="bg-accent text-white". Both the Tailwind utility and any var(--color-accent) fallback resolve correctly because both layers are loaded.

Auto-importing design system components

Wiring tokens is half the setup. The other half is making design system components available without manual imports on every page.

In Nuxt, components.dirs handles this:

// nuxt.config.ts
export default defineNuxtConfig({
  css: [
    'app/design-system/tokens.css',
    'app/assets/css/tailwind.css',
  ],
  components: {
    dirs: [
      { path: 'app/design-system/components', prefix: 'Ds' },
    ],
  },
})

A file at app/design-system/components/Card.vue becomes <DsCard /> anywhere in the app. No imports required. The Ds prefix avoids naming collisions with third-party UI libraries.

In React, there's no equivalent auto-import mechanism. A barrel export is the standard approach:

// app/design-system/index.ts
export { default as DsCard } from './components/Card'
export { default as DsButton } from './components/Button'

Then import from the barrel wherever you need it:

import { DsCard, DsButton } from '@/design-system'

This doesn't match Nuxt's zero-import DX, but it keeps the import path stable. Rename or move a component and you update the barrel once.

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 the order in the css array in nuxt.config.ts and confirm the file path is correct relative to the project root.

If the second grep returns nothing, the @theme block isn't being processed. Confirm that tailwind.css starts with @import "tailwindcss" and that the semantic token names in the @theme block match what you're using in markup.

For Next.js, run next build and inspect .next/static/css/. The generated stylesheet should contain both the :root block from tokens.css and the .bg-accent rule from the processed @theme block.

Key insights

Tailwind v4 doesn't inline arbitrary @imports. The @import "tailwindcss" directive is special-cased. Other imports aren't guaranteed to land in the served output. Use the framework's CSS loading mechanism instead.

Two layers are intentional. One ships :root vars. The other generates utilities. They do different things, and that's by design.

Curl the served CSS. A component that looks visually correct doesn't mean the tokens are wired correctly. A missing token that falls back to inherit or transparent looks fine until it doesn't.

Each stack differs slightly. Nuxt uses css: [] in config. Next.js uses import order in layout.tsx. The mental model is the same. The mechanism isn't.

A future build step can automate the @theme block. Right now we maintain the semantic mappings manually. That's acceptable for a small token set. As the system grows, a script that generates the @theme block from tokens.css becomes worth writing. The two-file structure supports that without changes.

Trials and errors

Getting here took longer than it should have.

The first dead end was the @import inside tailwind.css, described above. After that didn't work, I tried postcss-import as a PostCSS plugin. The reasoning: if Tailwind's own import handling was the problem, resolve the import at the PostCSS level before Tailwind sees it. That worked inconsistently. The result depended on plugin order, which version of postcss-import was installed, and whether Nuxt's Vite plugin ran before or after PostCSS. Non-deterministic behavior in a CSS pipeline is not something worth debugging past a first attempt.

I also misread the Tailwind v4 docs on @theme inline. The inline keyword affects how CSS variables are output in the generated stylesheet, not how external files are imported. I spent real time thinking it was the mechanism I needed. It wasn't relevant to the problem.

The fix became obvious once I stopped trying to make Tailwind handle the file and asked what Nuxt already does well. Nuxt loads CSS reliably. Give it the file. Let Tailwind do what it's built for: generating utilities from a @theme block. Keep the jobs separate.

That's the version we use on every project now.

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.