Tooling · 10 min read

Tailwind v4 ESLint migration: a deterministic upgrade guide

What changes in the class generation, which lint rules go stale, and the deterministic checks that turn the migration into a single boring pull request.

10 min readRelated rules

Tailwind v4 is the largest change to the project since utilities were introduced. The Oxide engine rewrites the class scanner, the configuration model moves from tailwind.config.js into CSS, and a small but real list of utilities renamed, consolidated, or were removed outright.

For most teams the runtime upgrade is mechanical. The risky part is the layer of tooling that grew on top of v3 — the ESLint config, the editor plugins, the AI agents that write Tailwind from training data still anchored in v3 syntax. Those do not migrate themselves. This post is a punch-list for the parts that bite.

What actually changes for the linter

Three categories of change matter for any tool that inspects Tailwind classes — your ESLint plugin, your editor, and any AI agent generating markup against your tokens.

1. Configuration moves into CSS

v3 read its scale, theme, and plugin list from a JS module. v4 reads from a CSS file via @theme and friends:

/* app/styles.css — v4 */
@import "tailwindcss";

@theme {
  --color-brand-primary: #1A5276;
  --color-brand-primary-dark: #173F62;
  --spacing-18: 4.5rem;
  --font-display: "Satoshi", system-ui;
}

Any linter that read your token scale from tailwind.config.js — including the older versions of eslint-plugin-tailwindcss and most internal rule wrappers — now reads from a file that is empty. Your "valid colours" allowlist silently becomes the empty set, every class in your codebase becomes legal, and every off-token hex an agent generates becomes invisible.

2. A small set of utilities renamed

v4 cleaned up some long-running inconsistencies. The shadow, blur, and rounded scales gained an -xs tier; the old -sm aliases shifted; opacity utilities like bg-opacity-50 are deprecated in favour of the slash syntax bg-black/50.

v3v4Notes
shadow-smshadow-xsOld shadow-sm reassigned to a heavier value.
rounded-smrounded-xsSame scale shift; existing rounded-sm usages render larger.
bg-opacity-50bg-black/50Slash syntax is the canonical form. The old *-opacity-* family still works in v4.0 but is deprecated.
flex-shrink-0shrink-0Aliases consolidated; both still emit but the canonical is shorter.
space-x-4 / space-y-4gap-4 (preferred)Still emitted, but flex/grid gap is the recommended pattern.

These shifts are mostly safe at the runtime layer — but a stale ESLint rule that hard-codes the v3 names will either flag valid v4 code, or worse, miss the deprecated form entirely. AI coding agents trained primarily on v3 corpora produce them by default.

3. The Oxide engine's scan surface

v4's class extraction is faster and stricter. Class strings built dynamically with template literals (`bg-${shade}`) are no longer guaranteed to be discovered. v3 lint rules that relied on the same template-literal heuristic stop matching what the runtime actually compiles. The fix is the one Tailwind has recommended for years — only ever pass full class names as strings — but the migration window is when the divergence surfaces.

The migration in five steps

Most projects can run the official codemod and ship the upgrade in one PR. The steps below assume an existing v3 setup with ESLint, Prettier, and at least one Tailwind plugin enabled.

  1. 01

    Run the codemod

    npx @tailwindcss/upgrade@latest

    This rewrites tailwind.config.js into a CSS @theme block, swaps the renamed utilities in your source files, and updates your dependencies. Read the diff — do not blindly accept it. The codemod is conservative with custom plugins.

  2. 02

    Replace the ESLint plugin

    eslint-plugin-tailwindcss through v3.x targets the v3 class set. If you depended on its no-custom-classname or classnames-order rules, pin a v4-compatible release or move to a tool that reads from CSS @theme directly. Either way the install line changes:

    # remove
    pnpm remove eslint-plugin-tailwindcss
    
    # install — pick one
    pnpm add -D eslint-plugin-tailwindcss@next   # community v4 track
    pnpm add -D @deslint/eslint-plugin           # reads tokens from @theme
  3. 03

    Re-import your tokens into the lint config

    This is the step most teams forget. The codemod migrates the runtime token source to CSS, but your linter still needs to know what is allowed. Mirror the values declared inside your @theme block into the designSystem section of .deslintrc.json — colors, spacing, radii, fonts. If you also publish a Style Dictionary or Stitch token file as part of your build, npx deslint import-tokens can pull from there directly:

    npx deslint import-tokens --style-dictionary ./tokens/build/tokens.json

    Once the allowlist matches your @theme exactly, no-arbitrary-colors, no-arbitrary-spacing, and no-arbitrary-typography stop drifting against the new runtime.

  4. 04

    Sweep the renamed utilities

    The codemod handles the obvious cases. Anything generated after the codemod ran — a stray PR opened against the v3 branch, an AI agent with v3 priors — will keep producing bg-opacity-*, shadow-sm at the old weight, and flex-shrink-0. A targeted lint sweep catches them:

    # deslint flags v3 classes that drift back in
    npx deslint scan

    The same no-conflicting-classes rule that catches flex hidden also catches the v3/v4 cohabitation patterns — for example shadow-sm shadow-md from a half-applied rename.

  5. 05

    Lock the gate in CI

    The migration is only complete when v3 patterns can no longer enter the codebase. Wire the deslint scan into your PR check — it runs in seconds, has zero cloud dependency, and the budget gate halts the merge if drift returns:

    # .github/workflows/lint.yml — relevant step
    - name: Deslint
      run: npx @deslint/cli scan --budget .deslint/budget.yml

The bit nobody talks about: AI agents are still on v3

Most coding agents' training corpora skew heavily toward v3 Tailwind. That means even after your runtime is on v4, your Claude Code, Cursor, Codex, and Windsurf sessions will keep generating v3-shaped class strings: the old shadow-sm weight, the deprecated bg-opacity-* family, the tailwind.config.js file you just deleted.

The fix is the same fix you have for any context-poor generation: hand the agent a deterministic checker as a tool. When deslint runs as an MCP server, the agent calls analyze_and_fix before it commits — and the v3 patterns get rewritten into their v4 equivalents the same wayno-arbitrary-colors rewrites a hex into a token. No prompt engineering, no guessing.

What the agent sees on each call

{
  "rule": "no-conflicting-classes",
  "file": "components/Card.tsx",
  "line": 14,
  "message": "shadow-sm shifted in Tailwind v4. Use shadow-xs for the old weight.",
  "fix": { "from": "shadow-sm", "to": "shadow-xs" }
}

Three commands to verify the migration

# 1. install the v4-aware lint set
pnpm add -D @deslint/eslint-plugin @deslint/cli

# 2. update designSystem in .deslintrc.json to mirror your @theme block
#    (or pull from a Style Dictionary build with import-tokens)

# 3. measure
npx deslint coverage

Want the v4 check inside the AI loop?

The CLI tells you what drifted. The MCP server tells the agent before it writes. Same v4-aware rule set, single stdio subprocess, zero cloud.