March 19, 2026

Design system guardrails: Enforcing color token usage in code

How to use Tailwind v4's theme reset and ESLint to make design tokens the only path forward, so arbitrary hex values stop accumulating in your codebase.

tailwind · eslint · design-system · dx

Every codebase has a hover:bg-[#1a1a1a] somewhere. It started as a quick fix, nobody flagged it in review, and now it's load-bearing. Multiply that across a team and a year, and the design system becomes a suggestion rather than a contract.

Stricter code review won't fix this. What works is making the wrong move harder than the right one, in two layers with Tailwind CSS v4 and ESLint: reset the default palette, define your tokens in CSS, then lint for anything that bypasses them.

#Layer 1: Make non-token colors ineffective (Tailwind v4)

Tailwind v4 is CSS-first. After @import "tailwindcss", you still get the full default palette (every shade of red, slate, zinc, and the rest) unless you clear it.

One line resets the entire color namespace:

css
@theme {
  --color-*: initial;
}

Then define your tokens by mapping CSS variables into @theme inline. The inline keyword tells Tailwind to reference the variables at use-time rather than resolving them upfront, which is what makes light/dark theming work correctly:

css
/* Token values live in :root and .dark */
:root {
  --primary: oklch(0.13 0.003 145);
  --destructive: oklch(0.52 0.14 21);
  --muted-foreground: oklch(0.18 0.06 142);
}
 
.dark {
  --primary: oklch(0.97 0.01 145);
  --destructive: oklch(0.23 0.05 13);
  --muted-foreground: oklch(0.79 0.02 134);
}
 
/* Map them into Tailwind's utility system */
@theme inline {
  --color-primary: var(--primary);
  --color-destructive: var(--destructive);
  --color-muted-foreground: var(--muted-foreground);
}

bg-red-500 now has nothing to resolve — it generates no useful CSS. bg-primary works correctly in both themes without a dark: prefix. The full color story lives in one file, and Tailwind utilities are how you reach it.

#Layer 2: Catch violations in the editor and CI (ESLint)

eslint-plugin-better-tailwindcss has native Tailwind v4 support by pointing it at your Tailwind entry stylesheet via settings["better-tailwindcss"].entryPoint. Point it at your global stylesheet and add a no-restricted-classes rule with two patterns.

eslint.config.mjs
js
import betterTailwind from "eslint-plugin-better-tailwindcss";
 
const COLOR_UTILS =
  "bg|text|border|ring|outline|fill|stroke|shadow|decoration|divide|caret|accent|from|via|to|placeholder";
 
const TAILWIND_COLORS =
  "slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|" +
  "emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose";
 
const VARIANTS = "(?:[a-z-]+:)*";
 
export default [
  {
    plugins: { "better-tailwindcss": betterTailwind },
    settings: {
      "better-tailwindcss": { entryPoint: "src/app/globals.css" },
    },
    rules: {
      "better-tailwindcss/no-restricted-classes": ["error", {
        restrict: [
          {
            // Named palette: bg-red-500, dark:text-slate-700, hover:border-zinc-300/50
            pattern: `^${VARIANTS}(${COLOR_UTILS})-(${TAILWIND_COLORS})(-\\d+)?(/\\d+)?$|^${VARIANTS}(text|bg)-(black|white)(/\\d+)?$`,
            message: "Prefer a design-system token (e.g. bg-primary, text-muted-foreground). If no token fits, add one to globals.css first.",
          },
          {
            // Arbitrary color values: bg-[#383838], dark:hover:bg-[#ccc]
            // Scoped to COLOR_UTILS so w-[158px], grid-cols-[1fr_2fr] etc. are unaffected.
            pattern: `^(?:[a-z-]+:)*(${COLOR_UTILS})-\\[.+\\]$`,
            message: "Avoid hardcoded color values. Use a design-system token or add a new one to globals.css if absolutely necessary.",
          },
        ],
      }],
    },
  },
];

The important detail is scope. Without restricting arbitrary values to COLOR_UTILS, you'd catch bg-[#fff] but also w-[158px], h-[100vh], and grid-cols-[1fr_2fr], none of which have anything to do with color.

Error messages are part of the design. A vague "don't do this" creates friction with no exit. Both messages above point to globals.css and frame it as "add a token," not "you're wrong."

#Before and after

A real example from migrating a Next.js starter page:

tsx
// Before — four different ways to express "dark background"
<div className="bg-zinc-50 dark:bg-black">
  <h1 className="text-black dark:text-zinc-50">...</h1>
  <a className="hover:bg-[#383838] dark:hover:bg-[#ccc]">Deploy</a>
  <a className="border-black/[.08] dark:border-white/[.145] hover:bg-black/[.04] dark:hover:bg-[#1a1a1a]">
    Docs
  </a>
</div>
 
// After — every color is a token
<div className="bg-background">
  <h1 className="text-foreground">...</h1>
  <a className="hover:bg-foreground/80">Deploy</a>
  <a className="border-border hover:bg-foreground/5 dark:hover:bg-muted">Docs</a>
</div>

The after version is shorter, theme-aware by default, and the intent of each class is clear from its name.

#How developers should work day to day

  1. Reach for a token first: bg-background, text-foreground, border-border, bg-destructive, text-muted-foreground, etc.
  2. Need something that doesn't exist? Add a variable to :root / .dark, register it in @theme inline, then use it. Don't inline the value.
  3. Pair with lint-staged so ESLint runs on staged files before commit. Same rules, earlier in the loop.

#What this doesn't solve

Lint can enforce token-shaped usage in Tailwind classes. It won't catch:

  • Inline style props with raw hex values
  • CSS modules or third-party components with baked-in colors
  • Wrong semantic choices: using bg-destructive for a warning is a design review problem, not a lint problem

Visual regression testing and design QA cover the gaps that static analysis can't.