DEV Community

Sergio Azócar
Sergio Azócar

Posted on • Edited on • Originally published at sergioazocar.com

oxlint-tailwindcss: the linting plugin Tailwind v4 needed

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.

{
  "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",
  },
}
Enter fullscreen mode Exit fullscreen mode

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:

{
  "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" },
      ],
    },
  },
}
Enter fullscreen mode Exit fullscreen mode

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:

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

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:

<div className="text-red-500 text-blue-500" />
// "text-red-500" and "text-blue-500" affect "color".
// "text-blue-500" takes precedence (appears later).
Enter fullscreen mode Exit fullscreen mode

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

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

// ✅
<div className="bg-white dark:bg-gray-900" />
Enter fullscreen mode Exit fullscreen mode

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:

// ❌ — dark:flex is redundant, flex already applies always
<div className="flex dark:flex" />
Enter fullscreen mode Exit fullscreen mode

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

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

// ✅ v4 (autofix)
<div className="grow text-ellipsis box-decoration-slice" />
Enter fullscreen mode Exit fullscreen mode

Also flex-shrinkshrink and decoration-clonebox-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-4ms-4, left-0start-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 for the build, oxfmt for formatting, vitest for testing, tsgo (native TypeScript 7 in Go) for type checking, and of course oxlint 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.

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. 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 helps more people find it.


GitHub · npm

Top comments (0)