npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

eslint-plugin-css-modules-guard

v2.0.0

Published

ESLint 9/10 plugin that validates CSS Modules against local CSS, SCSS, Sass, and Less source.

Readme

eslint-plugin-css-modules-guard

npm version npm downloads CI MIT license

Catch CSS Modules mistakes before they reach the browser. This ESLint 9 and 10 flat-config plugin reads your local .module.css, .module.scss, .module.sass, and .module.less files at lint time, so it can check the class names your code actually uses.

It compiles Sass and Less before checking selectors, understands local composes, and reports stylesheets that cannot be resolved or compiled. Use it in the editor and in CI; it does not need generated .d.ts files.

Install

Requires Node.js 20.19+ and ESLint 9 or 10.

npm install --save-dev eslint eslint-plugin-css-modules-guard

That is everything .module.css needs. Compilers are optional peer dependencies, so a project only pays for the ones it actually uses:

| Install | Enables | Range | | --- | --- | --- | | npm install --save-dev sass | .module.scss and .module.sass | ^1.45.0 | | npm install --save-dev less | .module.less | ^4.0.0 | | npm install --save-dev typescript | Aliases read from compilerOptions.paths in tsconfig.json | >=4.8.4 <6.1.0 |

Sass is 1.45.0 and up because that is where Dart Sass introduced the modern JavaScript API this plugin compiles through.

Most projects already have typescript in the tree: @typescript-eslint/utils requires it.

Nothing fails quietly when a compiler is absent. The import is reported by unresolvable-stylesheet with the exact package to install, and no-unknown-class stays silent on that module rather than inventing unknown classes:

Unable to compile CSS Module "./Card.module.scss". Install the optional peer dependency "sass" to lint .module.scss and .module.sass files

A real compile error still gets the plain Unable to compile message, so a broken stylesheet is never mistaken for a missing package.

Quick start

Add the recommended rules to your flat config:

// eslint.config.mjs
import cssModules from 'eslint-plugin-css-modules-guard';

export default [cssModules.configs.recommended];

Now even a computed typo is caught where it happens:

/* Button.module.css */
.primary {}
.danger {}
.legacy {}
// Button.js
import styles from './Button.module.css';

export function buttonClass(destructive) {
  const tone = destructive ? 'danger' : 'primray';
  return styles[tone];
}
Unknown CSS Module class "primray" in "./Button.module.css".

Correct primray to primary and the opt-in unused rule can safely report legacy: both possible computed keys are now accounted for. Direct typos such as styles.primray still receive an ESLint suggestion; the plugin never changes source code automatically.

What the recommended config checks

| Rule | Included | What it catches | | --- | --- | --- | | css-modules/no-unknown-class | Yes | Direct and finite computed class names that do not exist in the module | | css-modules/unresolvable-stylesheet | Yes | A CSS Module import that is missing, outside the project, unsafe, cannot compile, or needs a compiler that is not installed | | css-modules/no-unused-class | No | Local classes unused by one source file; enable only when that ownership rule is true |

no-unknown-class also accepts ICSS :export values, classes composed from local modules, and classes declared inside :global(). It resolves string literals, immutable const aliases, conditionals, templates, concatenation, and TypeScript as const expressions. Runtime values such as an API response are deliberately left indeterminate rather than guessed.

Configure the rules

The recommended config is enough for relative imports. Add the same options to every enabled CSS Modules rule when your project uses aliases, Sass load paths, or a different class-name convention.

// eslint.config.mjs
import cssModules from 'eslint-plugin-css-modules-guard';

const cssModulesOptions = {
  localsConvention: 'camelCase',
  aliases: {
    '@styles/*': 'src/styles/*',
  },
  loadPaths: ['src/styles'],
  suggestThreshold: 2,
  cache: true,
  cacheLimit: 512,
};

export default [
  cssModules.configs.recommended,
  {
    rules: {
      'css-modules/no-unknown-class': ['error', cssModulesOptions],
      'css-modules/unresolvable-stylesheet': ['error', cssModulesOptions],
      'css-modules/no-unused-class': ['warn', cssModulesOptions],
    },
  },
];

| Option | Default | Meaning | | --- | --- | --- | | localsConvention | 'asIs' | CSS-loader-style property names: asIs, camelCase, camelCaseOnly, or dashes | | aliases | {} | Local, project-root-relative mappings for CSS Module and Sass imports | | loadPaths | [] | Local compiler load paths, relative to the project root unless absolute | | sassLoadPaths | [] | Deprecated alias for loadPaths; both are honored and merged | | suggestThreshold | 2 | Maximum edit distance for unknown-class suggestions (0 disables suggestions) | | cache | true | Enables the in-memory stylesheet extraction cache | | cacheLimit | 256 | Maximum number of recently used stylesheets held in that cache |

The cache is content-hash validated and never creates cache files. Keep cacheLimit consistent across enabled CSS Modules rules: they share the in-memory cache.

Class-name conventions

Use localsConvention to match your CSS loader. For a class named .card-title:

| Convention | Accepted property names | | --- | --- | | asIs | styles['card-title'] | | camelCase or dashes | styles['card-title'] and styles.cardTitle | | camelCaseOnly | styles.cardTitle |

Aliases, Sass, and Less

The plugin automatically reads compilerOptions.paths from the nearest local tsconfig.json, including safe local extends files and project references. This uses the optional typescript peer dependency; without it, tsconfig aliases simply do not contribute and aliases below is the supported fallback.

// tsconfig.json
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@styles/*": ["src/styles/*"]
    }
  }
}

With that configuration, this import is checked without extra ESLint options:

import styles from '@styles/Button.module.scss';

Vite and webpack configuration is intentionally not read or executed. If an alias exists only there, repeat its local mapping in aliases as shown in the configuration example above.

Sass modules are compiled with Dart Sass (the optional sass peer dependency) before selector extraction. Nesting, mixins, local @use / @forward, static interpolation, and local composes therefore resolve to the selectors the module exposes.

/* Card.module.scss */
.card {
  &--active {
    display: block;
  }
}
import styles from './Card.module.scss';

styles['card--active'];

Less modules are compiled the same way, so nesting, & concatenation, mixins, selector interpolation, and local @import all resolve before selectors are read.

/* Card.module.less */
@import 'tokens';

.card {
  &--active {
    display: block;
  }
}
import styles from './Card.module.less';

styles['card--active'];

Less can execute JavaScript through its @plugin directive and inline backticks. Both are refused, and only .less and .css files inside the project are ever handed to the compiler: Less inlines the bytes of whatever it reads, and selector interpolation can turn them into a class name, so a stylesheet must not be able to read .env or a .js file.

That has one visible cost. data-uri() no longer inlines an image and degrades to a plain url(...), which is harmless — the stylesheet still compiles and exposes the same class names. image-size() has no such fallback in Less, so a stylesheet that calls it does not compile and is reported by unresolvable-stylesheet. If you need it, keep those rules out of .module.less files and in a plain stylesheet your build compiles.

Only local project files are followed. Paths outside the project root, symlink escapes, node_modules composition, remote URLs, and package imports are rejected, for Sass and Less alike.

Find unused classes

css-modules/no-unused-class is opt-in because it analyzes one source file at a time. Enable it when each CSS Module belongs to one component or source file:

export default [
  cssModules.configs.recommended,
  {
    rules: {
      'css-modules/no-unused-class': 'warn',
    },
  },
];

Direct access, finite computed access, and object destructuring count as use. Local composes dependencies count as use too. An indeterminate runtime access, passing the module object elsewhere, or Sass @extend makes the rule skip that module instead of issuing a risky false positive.

When the rule skips a module, it skips it entirely — no classes from that stylesheet are reported, even ones that really are unused, because the rule can no longer tell:

import styles from './Button.module.scss';

styles.root;               // counted as use
const { icon } = styles;   // counted as use
styles[iconVariant];       // runtime string: the whole module is skipped, not just this line
applyTheme(styles);        // passed elsewhere: the whole module is skipped too
/* Card.module.scss */
.card {
  @extend .panel; // any Sass @extend in the file skips the whole module, regardless of usage
}
/* Card.module.scss */
.base {}
.card {
  composes: base; // .base is used because .card composes it, even without a direct JS reference
}

Cross-file composes: base from './base.module.css' is different: it makes .base resolve as part of Card's exported properties for no-unknown-class, but it does not mark .base as used inside base.module.css — usage there still has to come from a JS reference in the same file that imports base.module.css.

Because the skip is whole-module, a single stray report almost never means "the rule missed one use case" — it means every usage found in that file was static, and the class in question genuinely wasn't one of them. The most common cause: the class is only ever used from a different source file. The rule only looks at the file that does the import, by design (see above); classes inside :global() are a local module's public API surface and are never checked by this rule at all, in either direction.

For stylesheets with several consumers, use the project-wide CLI instead:

css-modules-lint check-unused src

It exits with:

| Code | Meaning | | --- | --- | | 0 | No unused classes found | | 1 | Unused classes found | | 2 | The scan was incomplete or its input was invalid; no clean result is claimed |

The CLI parses source files with the optional typescript peer dependency. It follows local symbols to resolve the same immutable candidate sets across every importer. If a key comes from runtime data, the module object escapes, an import cannot be resolved, or TypeScript is unavailable, the scan exits 2 and says why instead of reporting false positives. --format json carries the explanation in a reason field when the cause can be named.

Use JSON in CI or pass the same alias and Sass settings when needed:

css-modules-lint check-unused src \
  --root . \
  --format json \
  --alias '@styles/*=src/styles/*' \
  --load-path src/styles \
  --locals-convention camelCase \
  --cache-limit 512

Available CLI options are --root <path>, --format <text|json>, repeatable --alias <prefix=path> and --load-path <path> (--sass-load-path is a deprecated alias), --locals-convention <value>, --no-cache, and --cache-limit <count>.

Editor autocomplete

This package validates code in ESLint and CI. For CSS Module completion and type hints in TypeScript editors, pair it with typescript-plugin-css-modules:

npm install --save-dev typescript-plugin-css-modules
// tsconfig.json
{
  "compilerOptions": {
    "plugins": [{ "name": "typescript-plugin-css-modules" }]
  }
}

The TypeScript plugin improves editor experience, but it does not run during tsc; keep this ESLint plugin for a repeatable local and CI check.

Coming from eslint-plugin-css-modules

eslint-plugin-css-modules is marked not maintained and only supports the legacy .eslintrc format, so it cannot be configured under ESLint 9/10 flat config at all. This package is a from-scratch flat-config replacement; it does not share code or a config format with it.

| eslint-plugin-css-modules | This package | Notes | | --- | --- | --- | | css-modules/no-undef-class | css-modules/no-unknown-class | Also checks finite computed keys and offers suggestions for direct typos | | css-modules/no-unused-class | css-modules/no-unused-class | Finite computed keys count as use; still opt-in because it assumes one file owns the module | | { camelCase: true } rule option | localsConvention: 'camelCase' plugin option | Shared across every enabled rule instead of set per rule | | settings['css-modules'].basePath | aliases / automatic tsconfig.json reading | No global base path; aliases are explicit or derived from compilerOptions.paths | | .eslintrc plugins + extends | flat config cssModules.configs.recommended | See Quick start |

To migrate: remove eslint-plugin-css-modules and its .eslintrc entries, install this package, and add cssModules.configs.recommended to your flat config as shown above. There is no automated codemod; the rule behavior is close enough that most projects only need to re-tune suggestThreshold and localsConvention after switching.

Scope and safety

  • Checks default imports ending in .module.css, .module.scss, .module.sass, or .module.less.
  • Resolves only finite local string expressions; it does not guess arbitrary runtime property names or inspect CSS-in-JS.
  • Reads regular local files only; performs no writes, subprocess execution, eval, or configuration execution.
  • Parses tsconfig.json as data and uses local-only Sass and Less file resolution.
  • Refuses Less @plugin directives and inline JavaScript, so a stylesheet cannot run code.
  • Reads only .less and .css files for Less, checked after symlink resolution.

See the security policy for reporting security issues.

Contributing

Found a bug, edge case, or missing CSS Modules pattern? Please open an issue with a minimal reproducer. See CONTRIBUTING.md for the development workflow and pull request checklist.

License

MIT