# oxlint-tailwindcss: the linting plugin Tailwind v4 needed

> The most complete linting plugin for Tailwind CSS, native to oxlint. 23 rules with autofix, deterministic and designed from scratch for Tailwind CSS v4.

## From problem to open-source: the perfect opportunity to contribute

The day the oxc team opened the alpha for native oxlint plugins, it was the perfect excuse to solve a problem I'd been having at my current job for a while: linting Tailwind CSS classes with oxlint.

If you use Tailwind CSS v4 with oxlint, the existing linting options aren't built for that combo. `eslint-plugin-tailwindcss` is solid but lives in the ESLint world and its v4 support is still partial. `eslint-plugin-better-tailwindcss` works in oxlint through the jsPlugins compatibility layer, and it gets the job done, but it's not a native plugin and its rules are more limited. Neither was designed specifically for oxlint + Tailwind CSS v4.

So I built it.

## What is oxlint-tailwindcss

A **native** oxlint plugin with 23 linting rules designed exclusively for Tailwind CSS v4. It's not an ESLint port or a wrapper, it uses the `@oxlint/plugins` API directly. Only two runtime dependencies, `@tailwindcss/node` and `tailwindcss`.

This matters because being native means it shares the same parsing cycle as oxlint. There's no interoperability overhead, no translation layer. It's as fast as oxlint itself, and it shows.

### Deterministic by design

To validate your classes, the plugin reads your real design system. And for that it needs just one thing, your CSS entry point, the file with `@import "tailwindcss"` and your `@theme` tokens. You declare it once in `settings.tailwindcss.entryPoint` and all 23 rules validate against the same design system.

```jsonc
{
  "jsPlugins": ["oxlint-tailwindcss"],
  "settings": {
    "tailwindcss": {
      "entryPoint": "src/styles.css", // your CSS with @import "tailwindcss"
    },
  },
  "rules": {
    "tailwindcss/no-unknown-classes": "error",
    "tailwindcss/no-conflicting-classes": "error",
    "tailwindcss/enforce-sort-order": "warn",
  },
}
```

Why declare it by hand instead of guessing it? Because guessing depends on the filesystem, and that means the same code can produce different results on your machine and in CI. Declaring the entry point explicitly is deterministic, same input, same output, on every machine. It's the same principle oxlint follows, and I'd rather have a plugin that's predictable than one that tries to be magic.

For the same reason, if you get the config wrong the plugin doesn't stay quiet and skip rules. It throws a single `designSystemUnavailable` diagnostic with a hint on what to fix. It fails loud and clear.

The plugin reads your CSS by calling `@tailwindcss/node` directly, so it understands your `@theme` tokens, your shadcn variables, and plugins like `@tailwindcss/typography` or `tailwindcss-animate`. The design system is computed once and cached on disk with a content hash, no recomputing on every run.

In a monorepo with several design systems you map globs to entry points, and the first match wins:

```jsonc
{
  "settings": {
    "tailwindcss": {
      "entryPoint": [
        { "files": "packages/ui/**", "use": "packages/ui/src/styles.css" },
        { "files": "packages/web/**", "use": "packages/web/src/app.css" },
        { "files": "**", "use": "src/global.css" },
      ],
    },
  },
}
```

The trailing `"**"` is the fallback for everything outside the explicit globs. If you prefer, you can also keep one `.oxlintrc.json` per package, both ways are 100% deterministic.

## 23 rules in four categories

### Correctness: catch real bugs

Correctness rules catch bugs before they reach the browser.

**no-unknown-classes** detects classes that don't exist in your design system and suggests fixes for typos:

```jsx
<div className="flex itms-center bg-blu-500" />
//                   ^^^^^^^^^^^
// "itms-center" is not a valid Tailwind CSS class.
// Did you mean "items-center"?
```

It supports `allowlist` to allow custom classes not in your design system and `ignorePrefixes` to skip prefixes that aren't Tailwind classes.

**no-conflicting-classes** tells you exactly which CSS property is conflicting and which class wins:

```jsx
<div className="text-red-500 text-blue-500" />
// "text-red-500" and "text-blue-500" affect "color".
// "text-blue-500" takes precedence (appears later).
```

**no-dark-without-light** detects when you use `dark:` without a base class, something that often causes missing styles in light mode:

```jsx
// ❌ — what background does it have in light mode?
<div className="dark:bg-gray-900" />

// ✅
<div className="bg-white dark:bg-gray-900" />
```

**no-dark-without-light** checks `dark:` by default, but the `variants` option lets you enforce the same pattern for any variant, useful if your project uses custom variants.

**no-contradicting-variants** catches redundant variants where the base class already applies unconditionally:

```jsx
// ❌ — dark:flex is redundant, flex already applies always
<div className="flex dark:flex" />
```

**no-deprecated-classes** automatically replaces classes deprecated in v4:

```jsx
// ❌ v3
<div className="flex-grow overflow-ellipsis decoration-slice" />

// ✅ v4 (autofix)
<div className="grow text-ellipsis box-decoration-slice" />
```

Also `flex-shrink` → `shrink` and `decoration-clone` → `box-decoration-clone`.

Plus the usual **no-duplicate-classes** (with autofix) and **no-unnecessary-whitespace**.

### Style: team consistency

**enforce-sort-order** sorts classes according to the official Tailwind CSS order (with autofix), compatible with oxfmt and prettier-plugin-tailwindcss. Its strict mode groups classes by variant prefix, sorts within each group, and orders groups by variant priority.

**enforce-shorthand** converts `mt-2 mr-2 mb-2 ml-2` to `m-2`, `w-full h-full` to `size-full`, and many more combinations. All with autofix.

**enforce-logical** converts physical properties to logical ones for LTR/RTL support: `ml-4` → `ms-4`, `left-0` → `start-0`. Its inverse, **enforce-physical**, does the opposite for projects that are LTR-only and prefer consistency with physical properties. Both with autofix.

**enforce-consistent-variable-syntax** normalizes CSS variable syntax between `bg-[var(--primary)]` and v4's shorthand `bg-(--primary)`.

**enforce-canonical** converts arbitrary values to native classes when they exist: `p-[2px]` → `p-0.5` (uses `rootFontSize` of 16px by default for conversion). It plugs directly into Tailwind's canonicalization API.

**enforce-consistent-important-position** (default suffix, v4's canonical form), **enforce-negative-arbitrary-values** (`-top-[5px]` → `top-[-5px]`), and **consistent-variant-order** round out the style rules.

### Complexity: keep code manageable

**max-class-count** warns when an element exceeds 20 classes (configurable). It's the signal that it's time to extract a component.

**enforce-consistent-line-wrapping** controls the class string length by print width or by number of classes per line.

### Restrictions: design system rules

**no-hardcoded-colors** forbids hardcoded colors like `bg-[#ff5733]` in arbitrary brackets, the typical shortcut that erodes your design system.

**no-arbitrary-value** and **no-unnecessary-arbitrary-value** (with autofix) control the use of arbitrary values. The latter detects when you use `h-[auto]` but `h-auto` exists.

**prefer-theme-tokens** detects when you reference a CSS variable by hand (`bg-[var(--primary)]` or the shorthand `bg-(--primary)`) and the named token utility exists in your design system, and rewrites it to `bg-primary`. It preserves opacity modifiers, variants, and `!important`. With autofix.

**no-restricted-classes** allows blocking specific classes by name or regex, with custom messages.

## Class extraction

The parser is what makes all of this work reliably. It's not a regex that looks for `className=` and hopes for the best. It extracts classes from:

- JSX attributes (`className`, `class`)
- Object-valued JSX attributes, e.g. Mantine's `<Input classNames={{ root: "flex", input: "border-none" }} />`
- Template literals with interpolation
- Ternaries
- Utility functions: `cn()`, `clsx()`, `cx()`, `cva()`, `twMerge()`, `twJoin()`, and more
- Full `cva()`: base, variants, compoundVariants
- Full `tv()`: base, slots, variants with slot objects, compoundSlots
- `classed()` (tw-classed): skips element type, extracts classes and cva-like config
- Tagged templates (`tw\`...``)
- Variables by name (`className`, `classes`, `style`, `styles`)
- Component classes defined with `@layer components { .btn {} }` in your CSS

It handles nested brackets, nested calc, arbitrary variants, quoted values, important modifier, negative values, and named groups/peers. The edge cases that break other parsers.

### Custom class detection

By default the plugin detects classes in common attributes, 14 utility functions, `tw` tagged templates, and variables named `className`/`classes`/`style`. You can extend these defaults via `settings.tailwindcss`, all values are additive:

```jsonc
{
  "settings": {
    "tailwindcss": {
      "attributes": ["overlayClassName"],
      "callees": ["myHelper"],
      "tags": ["css"],
      "variablePatterns": ["^tw"],
    },
  },
}
```

This applies to all 23 rules at once. If you need to remove a built-in default, use `exclude` in the same settings block.

## The story behind it

I started by planning what I wanted, the stack I was going to use, and how I wanted everything to work. After planning the implementation with **Claude Code**, the iteration began until reaching the current 23 rules. The repo includes a `CLAUDE.md` and configured skills that allow any contributor to use the same workflow to write new rules, the same tool the plugin was built with. If you want to add a rule, Claude Code already knows how to do it in this project.

The project runs entirely on the VoidZero tool ecosystem. [tsdown](https://tsdown.dev/) for the build, [oxfmt](https://oxc.rs/#feature-formatter) for formatting, [vitest](https://vitest.dev) for testing, [tsgo](https://github.com/microsoft/typescript-go) (native TypeScript 7 in Go) for type checking, and of course [oxlint](https://oxc.rs/#feature-linter) for linting the plugin itself. Every tool in the chain is built on Rust or optimized for speed.

It wasn't a cosmetic decision, it was deliberate dogfooding. If you're going to make a plugin for oxlint, it makes sense that the entire toolchain is from the same ecosystem. And if you're going to develop with an AI agent, it makes sense that the repo is prepared for it. That repo-ready-for-agents is exactly what I later systematized in [Context Architecture](/en/blog/context-architecture-the-whole-repo-is-the-context).

## Getting started

You need oxlint ≥ 1.43.0, Tailwind CSS v4, and Node.js ≥ 20. Install the plugin:

```bash
pnpm add -D oxlint-tailwindcss
```

Add the plugin, your entry point, and the rules to your `.oxlintrc.json`:

```jsonc
{
  "jsPlugins": ["oxlint-tailwindcss"],
  "settings": {
    "tailwindcss": {
      "entryPoint": "src/styles.css", // your CSS with @import "tailwindcss"
    },
  },
  "rules": {
    "tailwindcss/no-unknown-classes": "error",
    "tailwindcss/no-duplicate-classes": "error",
    "tailwindcss/no-conflicting-classes": "error",
    "tailwindcss/no-deprecated-classes": "error",
    "tailwindcss/no-unnecessary-whitespace": "error",
    "tailwindcss/enforce-sort-order": "warn",
    "tailwindcss/enforce-shorthand": "warn",
    "tailwindcss/no-hardcoded-colors": "warn",
    //...
  },
}
```

Run oxlint. That's it.

## Try it

The plugin is functional, tested, and used in production. But a linter gets better with real feedback from real projects.
If you try it and find a case it doesn't handle well, open an [issue](https://github.com/sergioazoc/oxlint-tailwindcss/issues). If you want to contribute a rule, the repo is already set up so you can iterate with Claude Code from minute one. And if it was simply useful to you, a star on [GitHub](https://github.com/sergioazoc/oxlint-tailwindcss) helps more people find it.

---

**GitHub** · **npm**
