Design-token lint rules for Tailwind v4 projects using Oxlint.
The rules keep color, typography, and corner radii inside your design system. They catch hard-coded colors, Tailwind palette colors, local dark-mode branches, invalid token names, and component color overrides. They also catch arbitrary typography and radii.
npm install --save-dev @evilmartians/design-lint oxlintCreate oxlint.config.ts:
import { defineConfig } from "oxlint";
import { designLint } from "@evilmartians/design-lint/preset";
export default defineConfig(
await designLint({
tokenFiles: ["src/styles.css"],
componentSources: ["@/components/ui/*"],
}),
);tokenFiles are the stylesheets that define your --color-* tokens.
componentSources are the import paths for your design-system components. Write them exactly as they appear in your code. For example, if your app imports #/components/ui/button, use #/components/ui/*, not @/components/ui/*.
If the project has no component library, pass componentSources: []. That turns off no-component-color-override.
Choose a preset with the preset option:
"strict"(default) enables all rules, including checks for token naming, themes, and component variants. It requirescomponentSources; pass[]if you have no component library."basic"enables checks that require only token stylesheets. OmitcomponentSources; this preset rejects it.
See Rules for the rules in each preset and their requirements.
await designLint({
preset: "basic",
tokenFiles: ["src/styles.css"],
});You can enable individual strict rules with basic; see Configuration.
Oxlint refuses to start when oxlint.config.ts and .oxlintrc.json sit in the same directory, and the setup above needs the TypeScript config. To keep your JSON config as it is, rename it (to oxlint.base.json, say) and merge it in:
import { defineConfig, type OxlintConfig } from "oxlint";
import { designLint } from "@evilmartians/design-lint/preset";
import json from "./oxlint.base.json" with { type: "json" };
const base = json as OxlintConfig;
const design = await designLint({
tokenFiles: ["src/styles.css"],
componentSources: ["@/components/ui/*"],
});
export default defineConfig({
...base,
jsPlugins: [...(base.jsPlugins ?? []), ...design.jsPlugins],
rules: { ...base.rules, ...design.rules },
});Merge it rather than passing it through extends: Oxlint rejects relative jsPlugins paths in a config that arrives through extends.
Example output:
src/App.tsx:5:46: error design(no-spectral-color): bg-red-500 — spectral color class; use bg-danger instead
src/App.tsx:8:21: error design(no-undefined-token): text-nonesuch generates no CSS — nonesuch is not defined; check the spelling, or add --color-nonesuch to your token stylesheetEnabled in both presets. These rules require only token stylesheets.
| Rule | What it reports |
|---|---|
no-raw-color |
Literal colors such as #f00, rgb(...), red, and bg-[#f00] |
no-spectral-color |
Tailwind palette classes such as bg-red-500 |
no-undefined-token |
Token-like color classes that generate no CSS |
no-style-color |
Color applied through React's style prop |
no-useless-hover |
Hover styles on elements that are not interactive |
no-arbitrary-typography |
Arbitrary font sizes, font weights, and letter spacing such as text-[13px] |
no-arbitrary-radius |
Arbitrary corner radii such as rounded-[6px] |
Enabled only in strict, alongside all basic rules. These checks rely on the conventions below.
| Rule | What it reports | Required convention |
|---|---|---|
token-constraints |
Valid tokens used in the wrong role, such as bg-muted-foreground |
Tokens named by role: *-foreground for text, *-hover for hover states |
no-dark-variant |
Local theme branches written with dark: or light-dark() |
Themes defined in token stylesheets |
no-opacity-modifier |
Color classes with opacity modifiers such as bg-primary/50 |
Named tokens for translucent colors |
no-component-color-override |
Color classes passed to design-system components through className |
Component variants that control appearance |
Each rule has a short guide in docs/rules: what it reports, what it allows, and how to fix it.
Override rule options or enable additional rules in your Oxlint config. Keep ...design.rules to preserve the preset’s rules:
const design = await designLint({
preset: "basic",
tokenFiles: ["src/styles.css"],
});
export default defineConfig({
...design,
rules: {
...design.rules,
"design/no-dark-variant": ["error", { flagNonColorUtilities: false }],
},
});When you restate a rule, you replace the options the preset gave it. If you only want to change severity, keep any required options with it. This matters most for no-component-color-override, because componentSources has no safe default.
To see how much of the design system a project leaves unchecked, run this from where you run oxlint:
npx @evilmartians/design-lint reportdesign-lint report — oxlint.config.ts
Disabled rules (1 of 12)
no-opacity-modifier off
Silenced (49)
no-raw-color 34
no-dark-variant 12
no-spectral-color 2
(all rules, none named) 1
Ignored paths (2)
src/legacy/** ignorePatterns
src/brand/art.tsx no-raw-color- Disabled rules are rules set to
offor omitted from your config. This includes strict rules when usingbasic, andno-component-color-overridewhencomponentSources: []. - Silenced adds line-disable rule mentions to recorded bulk suppression counts, broken down by rule, in files Oxlint lints. Each
oxlint-disable-line,oxlint-disable-next-line, or equivalenteslint-comment contributes one per named design rule. A comment naming no rules contributes one on its own line. Bulk counts come fromoxlint-suppressions.json, read from the directory Oxlint runs in. The total is an indicator of suppression use: a line comment can hide several violations or none, and recorded bulk counts can become stale. - Ignored paths lists the config's
ignorePatternsand files containing a block/* oxlint-disable */or/* eslint-disable */comment. Comment entries show the design rules named, or "all rules" when none are named. A block disable can cover only part of a file, for example when followed by an enable comment; its entry does not mean the entire file is unchecked.
It reads oxlint.config.ts the way Oxlint finds it. Pass --config <file> and paths to match how you run oxlint.
These rules are intentionally static. They do not run your app or follow values across files.
Known limits:
- CSS files are not linted yet. Classes in
@applyand colors in CSS declarations are out of scope for now. - Dynamically assembled classes are not checked. For example,
`bg-${tone}`is too ambiguous to validate. - Most rules check strings where they are written, not where a value eventually flows.
no-undefined-tokenonly checks clear class-list positions such asclassName, class helper calls,cva()andtv().
- Node 22.18+ or 23.6+
- Oxlint 1.82+
- Tailwind 4.1.18+
The package uses @tailwindcss/node to read the same design system your Tailwind build uses. It takes the copy your project resolves, so installing a newer one alongside an older Tailwind does not help: upgrade the project's Tailwind. If your project already uses Tailwind through Vite, PostCSS, or the Tailwind CLI, it is usually already installed. Otherwise add it:
npm install --save-dev @tailwindcss/nodeThe rules can also run in an ESLint v9 flat config. Add three things to your existing eslint.config.mjs:
// eslint.config.mjs
import { designLint } from "@evilmartians/design-lint/preset";
// 1. Resolve the design system.
const design = await designLint({
tokenFiles: ["src/styles.css"],
componentSources: ["@/components/ui/*"],
});
// 2. Then load the plugin.
const designPlugin = (await import("@evilmartians/design-lint/oxlint")).default;
export default [
// ...your existing config
// 3. Turn the rules on for your component files.
{
files: ["**/*.{js,jsx,ts,tsx}"],
plugins: { design: designPlugin },
rules: design.rules,
},
];Use a dynamic import() for the plugin. The designLint() call prepares the token data the rules need, and the plugin reads it when it loads.
The rules use whatever parser your config already sets for these files, so it must read JSX, and TypeScript if you use it.
See CONTRIBUTING.md.
MIT