ESLint plugin to enforce canonical Tailwind CSS class names using Tailwind CSS v4's canonicalization API.
- Features
- Comparison
- Installation
- Quick Start
- Configuration
- Options
- Usage Examples
- How It Works
- Limitations
- Troubleshooting
- Contributing
- License
- Related Links
- π Automatic Detection: Automatically detects non-canonical Tailwind CSS class names in JSX/TSX, Svelte, and Vue files
- π§ Auto-fix Support: Automatically fixes non-canonical classes using ESLint's auto-fix feature
- π― Tailwind CSS v4 Integration: Uses Tailwind CSS v4's official
canonicalizeCandidatesAPI - π Multiple Format Support: Works with string literals, template literals, ternaries,
&&/||, arrays, and object keys - π οΈ Utility Function Support: Detects and fixes classes in utility functions like
cn(),clsx(),classNames(),twMerge(),cva(), andtv(), and intw`β¦`tagged templates - β‘ Zero Config: Minimal configuration required to get started
Two larger plugins also ship a canonical-classes rule built on the same Tailwind API: eslint-plugin-tailwindcss (enforces-canonical-classname) and eslint-plugin-better-tailwindcss (enforce-canonical-classes). Choose this plugin when you want only canonical class names, with no other Tailwind rules to configure.
| This plugin | eslint-plugin-tailwindcss |
eslint-plugin-better-tailwindcss |
|
|---|---|---|---|
| Scope | Canonical classes only | Full Tailwind rule set | Full Tailwind rule set |
px β spacing scale (p-[16px] β p-4) |
On by default | No | With rootFontSize |
Two classes with the same canonical form (p-[1rem] p-4) |
Fixes only p-[1rem] |
Rewrites the wrong classes | Not reported |
| Rule time, 400 JSX files, 12k class strings, first run | 1.2 s | 1.2 s | 9.8 s |
| Same, next runs (on-disk cache) | 0.1 s | 1.2 s | 9.8 s |
Ternaries, template literals with ${}, clsx object keys, Vue :class object/array bindings |
Checked | Checked | Checked |
cva variants, tv(), tw`β¦` tagged templates |
Checked | Checked | Checked |
Measured on 2026-10-20 with ESLint 9.39, Tailwind CSS 4.3.3, eslint-plugin-tailwindcss 4.4.0, and eslint-plugin-better-tailwindcss 4.10.0. Rule time is total lint time minus a run with the rule off, median of five runs.
Install the plugin and its peer dependency:
npm install --save-dev eslint-plugin-tailwind-canonical-classes @tailwindcss/nodeOr with yarn:
yarn add -D eslint-plugin-tailwind-canonical-classes @tailwindcss/nodeOr with pnpm:
pnpm add -D eslint-plugin-tailwind-canonical-classes @tailwindcss/node- Node.js >= 18.0.0
- ESLint 8, 9, or 10
- Tailwind CSS v4
- @tailwindcss/node package
- svelte-eslint-parser (optional, required for Svelte support)
- vue-eslint-parser (optional, required for Vue support)
| ESLint Version | Flat Config (eslint.config.*) |
Legacy Config (.eslintrc.*) |
|---|---|---|
| 8.x | Supported | Supported |
| 9.x | Supported | Supported |
| 10.x | Supported | Not available (removed by ESLint) |
Note: ESLint 10 removes
.eslintrcsupport entirely. If you are upgrading to ESLint 10, you must migrate to flat config.
-
Install the plugin (see Installation)
-
Add to your ESLint config (
eslint.config.mjs):import tailwindCanonicalClasses from 'eslint-plugin-tailwind-canonical-classes'; export default [ ...tailwindCanonicalClasses.configs['flat/recommended'], { rules: { 'tailwind-canonical-classes/tailwind-canonical-classes': [ 'warn', { cssPath: './app/styles/globals.css', }, ], }, }, ];
-
Run ESLint:
npx eslint . --fix
Using the built-in flat/recommended config:
import tailwindCanonicalClasses from 'eslint-plugin-tailwind-canonical-classes';
export default [
...tailwindCanonicalClasses.configs['flat/recommended'],
{
rules: {
'tailwind-canonical-classes/tailwind-canonical-classes': [
'warn', // or 'error'
{
cssPath: './app/styles/globals.css', // Required
rootFontSize: 16, // Optional, default: 16
calleeFunctions: ['cn', 'clsx'], // Optional, default: ['cn', 'clsx', 'classNames', 'twMerge', 'cva', 'tv', 'tw']
},
],
},
},
];Install the Svelte parser alongside the plugin:
npm install --save-dev svelte-eslint-parserThen extend the built-in config:
import tailwindCanonicalClasses from 'eslint-plugin-tailwind-canonical-classes';
export default [
...tailwindCanonicalClasses.configs['flat/svelte'],
{
rules: {
'tailwind-canonical-classes/tailwind-canonical-classes': [
'warn',
{
cssPath: './src/app.css',
},
],
},
},
];Install the Vue parser alongside the plugin:
npm install --save-dev vue-eslint-parserThen extend the built-in config:
import tailwindCanonicalClasses from 'eslint-plugin-tailwind-canonical-classes';
export default [
...tailwindCanonicalClasses.configs['flat/vue'],
{
rules: {
'tailwind-canonical-classes/tailwind-canonical-classes': [
'warn',
{
cssPath: './src/style.css',
},
],
},
},
];Or with manual plugin registration:
import tailwindCanonicalClasses from 'eslint-plugin-tailwind-canonical-classes';
export default [
{
plugins: {
'tailwind-canonical-classes': tailwindCanonicalClasses,
},
rules: {
'tailwind-canonical-classes/tailwind-canonical-classes': [
'warn',
{
cssPath: './app/styles/globals.css',
},
],
},
},
];Deprecated: Legacy config (
.eslintrc) is not supported by ESLint 10. Migrate to flat config above.
module.exports = {
plugins: ['tailwind-canonical-classes'],
rules: {
'tailwind-canonical-classes/tailwind-canonical-classes': [
'warn',
{
cssPath: './app/styles/globals.css',
rootFontSize: 16,
calleeFunctions: ['cn', 'clsx'],
},
],
},
};- Type:
string - Description: Path to your Tailwind CSS file
- Supported formats:
- Relative path: Resolved relative to your project root (where ESLint config is located)
- Absolute path: Full filesystem path to your CSS file
Examples:
cssPath: './app/styles/globals.css' // Relative to project root
cssPath: './src/index.css' // Another relative example
cssPath: '/absolute/path/to/styles.css' // Absolute path- Type:
number - Default:
16 - Description: Root font size in pixels for rem calculations. This should match your CSS root font size setting.
Example:
rootFontSize: 16 // Default (16px = 1rem)
rootFontSize: 14 // If your root font size is 14px- Type:
string[] - Default:
['cn', 'clsx', 'classNames', 'twMerge', 'cva', 'tv', 'tw'] - Description: Names of functions whose arguments hold Tailwind classes. A name also matches tagged templates, such as
tw`β¦`andtw.div`β¦`. Forcvaandtv, classes are read from the config values (base,slots, variant options, andclass/classNamein compound entries);defaultVariantsis skipped.
Example:
calleeFunctions: ['cn', 'clsx'] // Only check cn() and clsx()
calleeFunctions: ['customFn'] // Check a custom utility functionBefore:
<div className="p-4px m-2rem">Content</div>After auto-fix:
<div className="p-1 m-8">Content</div>The plugin supports various class name formats:
-
String literals:
<div className="p-4 m-2">Content</div>
-
Template literals, including ones with
${}expressions:<div className={`p-4px ${size} m-2rem`}>Content</div> // A class touching ${} (such as bg-${color}) is only part of a class and is skipped
-
Conditional expressions, arrays, and object keys:
<div className={active ? "p-4px" : "m-2rem"}>Content</div> <div className={clsx({ "w-[16px]": active }, ["h-[32px]"])}>Content</div>
-
Utility functions (e.g.,
cn(),clsx(),classNames(),twMerge(),cva()):<div className={cn("p-4px", "m-2rem")}>Content</div> <div className={clsx("w-[16px]", condition && "hidden")}>Content</div> const button = cva("w-[16px]", { variants: { size: { sm: "h-[32px]" } } }); const card = tv({ base: "p-4px", slots: { title: "m-2rem" } }); const Title = tw.h1`text-[16px]`; // Variables and other dynamic values are skipped
// Before
function Card({ children }) {
return (
<div className="p-16px m-2rem rounded-8px shadow-lg">
{children}
</div>
);
}
// After auto-fix
function Card({ children }) {
return (
<div className="p-4 m-8 rounded-2 shadow-lg">
{children}
</div>
);
}// Before
import { cn } from '@/lib/utils';
function Button({ variant, className }) {
return (
<button className={cn("w-[16px]", "h-[32px]", className)}>
Click me
</button>
);
}
// After auto-fix
import { cn } from '@/lib/utils';
function Button({ variant, className }) {
return (
<button className={cn("w-4", "h-8", className)}>
Click me
</button>
);
}- Load Design System: The plugin loads your Tailwind CSS file using
@tailwindcss/node's worker API - Extract Classes: It extracts class names from:
- JSX
classNameattributes - Standalone script-level utility function calls (e.g.
cn(),clsx()) outside class attributes - Svelte
classattributes, static text and{...}expressions - Vue
class,:class, andv-bind:classattributes, including object and array bindings - Inside expressions it checks string literals, template literal text, both sides of
? :,&&, and||, array elements, object keys, and arguments of nested utility calls
- JSX
- Canonicalize: For each class, it uses Tailwind's
canonicalizeCandidatesto find the canonical form - Report & Fix: If a non-canonical class is found, it reports an error/warning and can auto-fix it
Tailwind needs about a second to build its canonicalization index the first time it sees a class. To skip that on later runs, the plugin stores every class it has canonicalized in node_modules/.cache/eslint-plugin-tailwind-canonical-classes/ under the ESLint working directory, or in the system temp directory when there is no node_modules. A run that finds all of its classes there never starts Tailwind.
The cache is tied to the plugin version, the Tailwind version the plugin runs, rootFontSize, and every file Tailwind read to load your CSS: the stylesheet, its local imports, and imported package files such as tailwindcss/index.css. Editing or upgrading any of them starts a fresh cache, including inside a running editor or eslint_d. Old cache files are never pruned; delete the directory to reclaim space.
Set TAILWIND_CANONICAL_CLASSES_CACHE_DIR to store the cache somewhere else, for example a directory your CI caches between runs.
- Static classes only: Only works with static class names (no dynamic expressions)
- Tailwind CSS v4 required: Requires Tailwind CSS v4 (not compatible with v3)
- CSS file accessibility: CSS file must be accessible from the ESLint process
- Template literals: A class touching a
${}expression (for examplebg-${color}) is skipped - Dynamic values: Variables, computed object keys, and
clsxobject values are skipped cva/tvclass objects: Aclsx-style object written directly in acvaortvconfig (for examplebase: { 'w-4': active }) is read as config, so its class keys are skipped; wrap it inclsx()to have it checked- Tagged templates: Only
tw`β¦`andtw.<tag>`β¦`match; deeper chains such astw.colors.div`β¦`are skipped - Framework parsers:
flat/svelteandflat/vuerequiresvelte-eslint-parserandvue-eslint-parserrespectively
Problem: The plugin isn't reporting any issues with non-canonical classes.
Solutions:
- Verify your
cssPathis correct and points to a valid Tailwind CSS file - Ensure the CSS file is accessible from where ESLint runs
- Check that your Tailwind CSS file contains valid Tailwind directives (
@import "tailwindcss"or similar) - Verify ESLint is processing your JSX/TSX files (check your ESLint config includes these file types)
Problem: ESLint can't find your CSS file.
Solutions:
- Use an absolute path if relative paths aren't working
- Ensure the path is relative to your ESLint config file location
- Check file permissions
Problem: ESLint reports Tailwind could not canonicalize classes with <path>: <error> once per file and checks no classes in it.
Solutions:
- Tailwind failed to load your CSS file. The message ends with Tailwind's own error.
- Fix the import, plugin, or syntax it names, for example a package that is not installed in this project
Problem: ESLint reports issues but doesn't auto-fix them.
Solutions:
- Run ESLint with the
--fixflag:npx eslint . --fix - Ensure your editor's ESLint extension has auto-fix enabled
- Check that the rule severity is set to
'warn'or'error'(not'off')
Contributions are welcome! Please feel free to submit a Pull Request.
- Fork the repository
- Create your feature branch (
git checkout -b feature/amazing-feature) - Commit your changes (
git commit -m 'feat: add some amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
# Clone the repository
git clone https://github.com/MaisonnatM/eslint-plugin-tailwind-canonical-classes.git
cd eslint-plugin-tailwind-canonical-classes
# Install dependencies
npm install
# Build the project
npm run build
# Run tests
npm testThis project uses semantic-release for automated version management and npm publishing. Releases are automatically triggered when commits are pushed to the main branch.
Commit Message Format:
fix:- Patch release (1.0.8 β 1.0.9)feat:- Minor release (1.0.8 β 1.1.0)feat!:orBREAKING CHANGE:- Major release (1.0.8 β 2.0.0)
MIT Β© Maisonnat Maxence