Skip to content

About

Design-token lint rules for Tailwind v4 codebases, for Oxlint and ESLint

Resources

Contributing

Stars

14 stars

Watchers

0 watching

Forks

Repository files navigation

@evilmartians/design-lint

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 oxlint

Setup

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

Presets

Choose a preset with the preset option:

  • "strict" (default) enables all rules, including checks for token naming, themes, and component variants. It requires componentSources; pass [] if you have no component library.
  • "basic" enables checks that require only token stylesheets. Omit componentSources; 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.

Keeping an existing .oxlintrc.json

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 stylesheet

Rules

Basic

Enabled 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]

Strict

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.

Configuration

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.

Integration report

To see how much of the design system a project leaves unchecked, run this from where you run oxlint:

npx @evilmartians/design-lint report
design-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 off or omitted from your config. This includes strict rules when using basic, and no-component-color-override when componentSources: [].
  • 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 equivalent eslint- comment contributes one per named design rule. A comment naming no rules contributes one on its own line. Bulk counts come from oxlint-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 ignorePatterns and 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.

Limits

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 @apply and 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-token only checks clear class-list positions such as className, class helper calls, cva() and tv().

Requirements

  • 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/node

ESLint

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

Contributing

See CONTRIBUTING.md.

License

MIT

About

Design-token lint rules for Tailwind v4 codebases, for Oxlint and ESLint

Resources

Contributing

Stars

14 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages