---
title: "Design system guardrails: Enforcing color token usage in code"
description: "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."
date: "2026-03-19"
slug: "design-system-guardrails-color-tokens"
tags: ["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.

<Callout title="Caveat" tone="warning">
  This is a build-time guarantee. A developer can still _type_ `bg-red-500` and nothing breaks loudly at runtime; the class just won't do anything. That silence is what the next layer solves.
</Callout>

## 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.

```js title="eslint.config.mjs"
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.

<Callout title="The real goal" tone="info">
  The goal is to remove the decision, not to block developers. When `bg-[#1a1a1a]` errors and the message says "add a token to globals.css," the path forward is clear. Over time the token file becomes a complete record of every color in the product, and "just this once" hex values stop accumulating.
</Callout>
