Skip to content
toolingtailwindcssopen-source

oxlint-tailwindcss: the linting plugin Tailwind v4 needed

The most complete linting plugin for Tailwind CSS, native to oxlint. 22 rules with autofix, zero-config and designed from scratch for Tailwind CSS v4.

·
5 min read
read in Spanish

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 22 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 2 runtime dependencies.

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.

Works without configuration

The plugin auto-detects your Tailwind CSS entry point. If your file is called app.css, globals.css, main.css, tailwind.css (or any of the conventional names) and contains @import "tailwindcss", it finds it automatically.

{
  "jsPlugins": ["oxlint-tailwindcss"],
  "rules": {
    "tailwindcss/no-unknown-classes": "error",
    "tailwindcss/no-conflicting-classes": "error",
    "tailwindcss/enforce-sort-order": "warn"
  }
}

The auto-detection follows @import statements one level deep — including package imports like @import '@company/theme/tailwind.config.css'. In monorepos, the search stops at package.json boundaries so each package resolves its own design system automatically.

The design system is loaded once per entry point, cached in memory and on disk. In a monorepo with multiple packages sharing the same entry point, the design system is loaded only once.

If you have a non-conventional entry point you can set it in settings.tailwindcss.entryPoint or per rule.

22 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"?

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

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" />

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" />

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" />

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.

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:

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

This applies to all 22 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 22 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

pnpm add -D oxlint-tailwindcss

Add the plugin to your .oxlintrc.json:

{
  "jsPlugins": ["oxlint-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