Skip to content

enforce-canonical ​

oxlint rule that rewrites Tailwind CSS classes to the canonical form Tailwind itself computes, like -m-0 → m-0, with an autofix.

At a glance ​

AutofixEditor suggestionsDesign systemOptions
YesYesRequired (entryPoint)entryPoint, reportNonEquivalent

What this rule does ​

Asks the design system for the canonical form of every utility in your code and rewrites the ones that aren't already canonical. "Canonical" is whatever canonicalizeCandidates() from @tailwindcss/node returns — the same source of truth used by prettier-plugin-tailwindcss, oxfmt, and the official Tailwind tooling. Examples: -m-0 → m-0 (no negative is needed for a zero), start-2 → inset-s-2, flex-grow-[2] → grow-2 (an arbitrary value whose named form emits identical CSS), text-[var(--color-text)]/90 → text-(--color-text)/90. Auto-fix lands the first hit, suggestions cover the rest in the same string. The v3 renames (bg-gradient-to-r → bg-linear-to-r, break-words → wrap-break-word) are owned by no-deprecated-classes, not this rule — see Interactions.

Named classes resolve through a precomputed in-memory canonicalMap (sub-microsecond). Arbitrary values (p-[2px], bg-(--c)) and arbitrary variants (data-[open]:, min-[40rem]:) go through the canonicalize-service worker because they need a live DS lookup; results are cached process-wide per (entryPoint, rem, class). The worker preserves the position of ! (important prefix vs suffix vs none).

DS-dependent — requires settings.tailwindcss.entryPoint. If the design system can't load, the rule emits a single fatal designSystemUnavailable diagnostic per file instead of silently passing.

Options ​

reportNonEquivalent ​

boolean, default false.

Some canonical forms are not the same CSS in your project, and the rule leaves those classes as written. With this option on, it also tells you about two kinds of them, never with an autofix:

  • A variant your CSS defines differently from stock Tailwind. shadcn/ui, for instance, ships data-disabled: as a custom variant built on :where([data-disabled="true"]), …, so data-[disabled]:opacity-50 — which stock Tailwind rewrites to data-disabled:opacity-50 — matches different elements here. The report names both variants, and switching is your call.
  • A canonical form that also sets a variable other utilities read. Tailwind rewrites [line-height:var(--x)] to leading-(--x), but leading-(--x) also sets --tw-leading, which every text-* size reads for its line height, so next to one the two can render differently. The same goes for [border-style:dashed] → border-dashed (--tw-border-style, read by border-2), [transition-duration:300ms] → duration-300 (--tw-duration, read by transition), and the font-*, tracking-*, ease-*, outline-* and content-none forms. The report names the variable and carries a suggestion your editor can apply.

A difference stock Tailwind has too (has-[[data-slot=x]]: vs has-data-[slot=x]: emit different selectors anywhere) isn't reported, and neither is a value that reads the theme (p-[2px] vs p-0.5, see prefer-scale-token). On shadcn/ui's apps/v4 the option adds 23 reports, all data-[disabled]:, and none of the second kind.

jsonc
{ "tailwindcss/enforce-canonical": ["warn", { "reportNonEquivalent": true }] }

entryPoint ​

string, optional. Per-rule override of settings.tailwindcss.entryPoint. Configure the entry point in settings.tailwindcss.entryPoint for the whole project instead whenever possible.

Examples ​

✗ Incorrect ​

tsx
// Negative-of-zero is just zero
<div className="-m-0 -mt-0" />
// → <div className="m-0 mt-0" />

// Logical inset shorthand → canonical inset-s-* / inset-e-*
<div className="start-2 end-4" />
// → <div className="inset-s-2 inset-e-4" />

// An arbitrary value whose named form emits identical CSS
<div className="flex-grow-[2]" />
// → <div className="grow-2" />

// Variants and important are preserved
<div className="hover:!flex-grow-[2]" />
// → <div className="hover:!grow-2" />

// Opacity modifiers and arbitrary variants, with or without other variants
<div className="dark:bg-white/[.08] data-[open]:flex min-[40rem]:flex" />
// → <div className="dark:bg-white/8 data-open:flex sm:flex" />

// options: { "reportNonEquivalent": true }
// Not the same CSS: leading-(--x) also sets --tw-leading, which text-* sizes read.
// Reported with a suggestion, never fixed
<div className="[line-height:var(--x)]" />
// → <div className="leading-(--x)" />

✓ Correct ​

tsx
<div className="m-0 mt-0" />

<div className="inset-s-2 inset-e-4" />

<div className="grow-2" />

<div className="hover:!grow-2" />

<div className="dark:bg-white/8 data-open:flex sm:flex" />

<div className="leading-(--x)" />

Interactions with other rules ​

  • no-unnecessary-arbitrary-value: complementary, no double-fire. Both rewrite an arbitrary value to a named utility only when the two emit identical CSS; they split on shape. no-unnecessary-arbitrary-value owns the cases where the arbitrary maps directly to a single named utility (e.g. h-[auto] → h-auto). A value that is only numerically equal to a scale step (p-[2px] → p-0.5, whose CSS text differs) is neither rule's business — that is prefer-scale-token, report-only.
  • prefer-theme-tokens: the third partner of the arbitrary→named trio. It catches CSS-var references like border-(--border) → border-border where neither enforce-canonical (the CSS differs) nor no-unnecessary-arbitrary-value (no shared bracket equivalent) would fire.
  • no-deprecated-classes: it owns the v3 renames, and this rule skips them. Tailwind canonicalizes them too, so bg-gradient-to-r → bg-linear-to-r used to be reported twice with the same fix; the other rule's message ("deprecated in v4") is the more actionable of the two. What stays here is everything that is current-but-not-canonical: -m-0 → m-0, start-2 → inset-s-2, and arbitrary-valued forms like flex-grow-[2] → grow-2 (the rename list holds spellings, not values). Keep both rules on — with only this one enabled the renames go unreported.
  • prefer-scale-token: the report-only half of what this rule gave up in #78. A rewrite whose emitted CSS differs textually (p-[10px] → p-2.5, where the token resolves through var(--spacing)) is not autofixed here and never will be; that rule reports it with a suggestion instead. The two can never both fire: this one only rewrites byte-identical pairs, which is exactly what the other one skips.
  • enforce-consistent-important-position: this rule preserves the ! position you wrote (prefix vs suffix vs none). enforce-consistent-important-position is the single source of truth for enforcing a particular position.

When to disable it ​

  • You don't want any class rewrites at all — for example, in a legacy file you're keeping verbatim for diffing.
  • Performance-sensitive lint runs where the worker initialization cost is unwelcome. The cache makes subsequent runs cheap, but the first hit pays for @tailwindcss/node startup. Most projects will not notice.
  • You haven't migrated to Tailwind v4 yet: the canonical forms produced here assume v4 semantics.

Released under the MIT License.