Skip to content

About

Eslint plugin to handle canonical classes.

Topics

Resources

Stars

27 stars

Watchers

0 watching

Forks

Repository files navigation

eslint-plugin-tailwind-canonical-classes

npm version npm downloads License: MIT Node.js Version

ESLint plugin to enforce canonical Tailwind CSS class names using Tailwind CSS v4's canonicalization API.

πŸ“‹ Table of Contents

✨ Features

  • πŸ” 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 canonicalizeCandidates API
  • πŸ“ 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(), and tv(), and in tw`…` tagged templates
  • ⚑ Zero Config: Minimal configuration required to get started

βš–οΈ Comparison

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.

πŸ“¦ Installation

Install the plugin and its peer dependency:

npm install --save-dev eslint-plugin-tailwind-canonical-classes @tailwindcss/node

Or with yarn:

yarn add -D eslint-plugin-tailwind-canonical-classes @tailwindcss/node

Or with pnpm:

pnpm add -D eslint-plugin-tailwind-canonical-classes @tailwindcss/node

Requirements

  • 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 Compatibility

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 .eslintrc support entirely. If you are upgrading to ESLint 10, you must migrate to flat config.

πŸš€ Quick Start

  1. Install the plugin (see Installation)

  2. 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',
            },
          ],
        },
      },
    ];
  3. Run ESLint:

    npx eslint . --fix

βš™οΈ Configuration

Flat Config (ESLint 8+) β€” Recommended

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']
        },
      ],
    },
  },
];

Svelte (Flat Config)

Install the Svelte parser alongside the plugin:

npm install --save-dev svelte-eslint-parser

Then 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',
        },
      ],
    },
  },
];

Vue (Flat Config)

Install the Vue parser alongside the plugin:

npm install --save-dev vue-eslint-parser

Then 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',
        },
      ],
    },
  },
];

Legacy Config (.eslintrc.js) β€” ESLint 8/9 only

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'],
      },
    ],
  },
};

πŸ“– Options

cssPath (required)

  • 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

rootFontSize (optional)

  • 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

calleeFunctions (optional)

  • 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`…` and tw.div`…`. For cva and tv, classes are read from the config values (base, slots, variant options, and class/className in compound entries); defaultVariants is skipped.

Example:

calleeFunctions: ['cn', 'clsx']  // Only check cn() and clsx()
calleeFunctions: ['customFn']    // Check a custom utility function

πŸ’‘ Usage Examples

Basic Example

Before:

<div className="p-4px m-2rem">Content</div>

After auto-fix:

<div className="p-1 m-8">Content</div>

Supported Formats

The plugin supports various class name formats:

  1. String literals:

    <div className="p-4 m-2">Content</div>
  2. 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
  3. 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>
  4. 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

Real-world Example

// 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>
  );
}

Utility Function Example

// 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>
  );
}

πŸ”§ How It Works

  1. Load Design System: The plugin loads your Tailwind CSS file using @tailwindcss/node's worker API
  2. Extract Classes: It extracts class names from:
    • JSX className attributes
    • Standalone script-level utility function calls (e.g. cn(), clsx()) outside class attributes
    • Svelte class attributes, static text and {...} expressions
    • Vue class, :class, and v-bind:class attributes, 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
  3. Canonicalize: For each class, it uses Tailwind's canonicalizeCandidates to find the canonical form
  4. Report & Fix: If a non-canonical class is found, it reports an error/warning and can auto-fix it

Cache

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.

⚠️ Limitations

  • 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 example bg-${color}) is skipped
  • Dynamic values: Variables, computed object keys, and clsx object values are skipped
  • cva/tv class objects: A clsx-style object written directly in a cva or tv config (for example base: { 'w-4': active }) is read as config, so its class keys are skipped; wrap it in clsx() to have it checked
  • Tagged templates: Only tw`…` and tw.<tag>`…` match; deeper chains such as tw.colors.div`…` are skipped
  • Framework parsers: flat/svelte and flat/vue require svelte-eslint-parser and vue-eslint-parser respectively

πŸ› Troubleshooting

Plugin not detecting classes

Problem: The plugin isn't reporting any issues with non-canonical classes.

Solutions:

  1. Verify your cssPath is correct and points to a valid Tailwind CSS file
  2. Ensure the CSS file is accessible from where ESLint runs
  3. Check that your Tailwind CSS file contains valid Tailwind directives (@import "tailwindcss" or similar)
  4. Verify ESLint is processing your JSX/TSX files (check your ESLint config includes these file types)

Path resolution issues

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

"Tailwind could not canonicalize classes"

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

Auto-fix not working

Problem: ESLint reports issues but doesn't auto-fix them.

Solutions:

  • Run ESLint with the --fix flag: 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')

🀝 Contributing

Contributions are welcome! Please feel free to submit a Pull Request.

  1. Fork the repository
  2. Create your feature branch (git checkout -b feature/amazing-feature)
  3. Commit your changes (git commit -m 'feat: add some amazing feature')
  4. Push to the branch (git push origin feature/amazing-feature)
  5. Open a Pull Request

Development Setup

# 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 test

Release Process

This 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!: or BREAKING CHANGE: - Major release (1.0.8 β†’ 2.0.0)

πŸ“„ License

MIT Β© Maisonnat Maxence

πŸ”— Related Links

About

Eslint plugin to handle canonical classes.

Topics

Resources

Stars

27 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages