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 — and stop arbitrary hex values from 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.

The fix isn't stricter code reviews — it's making the wrong move harder to make than the right one. This post walks through a two-layer setup for Tailwind CSS v4 and ESLint that does exactly that: 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 explicitly 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 just 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 firstbg-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 isn't a lint problem, it's a design review problem

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