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:
@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:
/* 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.
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:
// 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
- Reach for a token first —
bg-background,text-foreground,border-border,bg-destructive,text-muted-foreground, etc. - 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. - 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
styleprops with raw hex values - CSS modules or third-party components with baked-in colors
- Wrong semantic choices — using
bg-destructivefor 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.