eslint-plugin-css-modules-next
v1.6.1
Published
ESLint plugin for CSS modules validation — catches undefined and unused CSS classes
Maintainers
Readme
eslint-plugin-css-modules-next
ESLint plugin for CSS Modules validation. Catches undefined and unused CSS classes, enforces co-location conventions, and disallows dynamic class access patterns that cannot be statically verified.
Supports .css, .scss, and .less module files.
Motivation
This plugin is inspired by eslint-plugin-css-modules. That project is no longer maintained and has a number of unresolved issues. eslint-plugin-css-modules-next aims to provide the same core functionality while fixing many of the issues that existed with the original plugin.
Capabilities
ESLint v9 and v10 Uses flat config format and is fully compatible with ESLint v9 and v10.
Pseudo-class selectors
Classes referenced inside :not(), :where(), :is(), and :has() are correctly treated as local class names. A pattern like .root { &:not(.selected) { } } makes .selected a valid defined class — styles.selected will not produce a false positive. Classes inside :global() are correctly excluded since they belong to the global scope.
Nested classes
Classes defined in nested rules are each tracked as independent local class names, whether the nesting is CSS-native (.card { .header { } }) or a compound selector (.card .header { }). Both produce card and header as defined classes. SCSS and LESS nesting are handled equivalently.
import * as styles syntax
import * as styles from './Foo.module.css' works identically to import styles from './Foo.module.css'. All rules apply equally to both forms.
@keyframes blocks
Animation names in @keyframes are never treated as CSS class names. A stylesheet that defines @keyframes spin { } and uses animation: spin 1s infinite will not report spin as a defined or unused class.
:global and :local scoping
All three forms of :global are correctly handled: the function form (:global(.Foo)), the space form (:global .Foo { }), and the block form (:global { .Foo { } }) each exclude their nested classes from the module's local export set. Within a :global { } block, :local(.foo) re-enables local scope and the class is included as an export.
@value declarations
Named values exported via @value name: value; are included as valid module exports. styles.name backed by a @value declaration is treated the same as a class name — the plugin validates both.
:export blocks
Properties declared inside :export { } (the ICSS mechanism for publishing arbitrary values from a CSS module) are included as valid exports. styles.myProp backed by :export { myProp: something; } is treated the same as a class name.
Attribute selectors
Class-like strings that appear as attribute selector values — for example a[href=".notAClass"] — are stripped before parsing so they never produce phantom class names.
Installation
npm install --save-dev eslint-plugin-css-modules-next
# or
pnpm add -D eslint-plugin-css-modules-next
# or
yarn add --dev eslint-plugin-css-modules-nextUsage
Recommended config (flat config)
// eslint.config.js
import cssModules from 'eslint-plugin-css-modules-next';
export default [
cssModules.configs.recommended,
];The recommended config enables all four rules with the following severity:
| Rule | Severity |
|------|----------|
| css-modules-next/invalid-css-module-filepath | error |
| css-modules-next/no-dynamic-class-access | error |
| css-modules-next/no-undefined-class | error |
| css-modules-next/no-unused-class | error |
Manual config
// eslint.config.js
import cssModules from 'eslint-plugin-css-modules-next';
export default [
{
plugins: { 'css-modules-next': cssModules },
rules: {
'css-modules-next/invalid-css-module-filepath': 'error',
'css-modules-next/no-dynamic-class-access': 'error',
'css-modules-next/no-undefined-class': 'error',
'css-modules-next/no-unused-class': 'error',
},
},
];Settings
The shared settings['css-modules-next'] block is plugin-wide configuration consumed by every rule. It currently holds three keys: localsConvention, cacheSize, and relativePaths.
localsConvention (shared)
Type: "asIs" | "camelCase" | "camelCaseOnly"
Default: "asIs"
localsConvention describes how your CSS modules bundler exports kebab-case class names to JavaScript, and it needs to be consistent across both rules. Rather than repeating it in each rule's options (where the two copies can silently drift), set it once in shared settings as a single source of truth:
// eslint.config.js
export default [
{
settings: {
'css-modules-next': { localsConvention: 'camelCase' },
},
rules: {
'css-modules-next/no-undefined-class': 'error',
'css-modules-next/no-unused-class': 'error',
},
},
];Precedence is: a rule's own localsConvention option (if set) wins; otherwise the shared settings value applies; otherwise the "asIs" default. So shared settings apply to every rule, while any single rule can still override the convention for itself. See each rule's localsConvention option for the meaning of each value.
cacheSize (shared)
These rules read each CSS module file from disk and parse it to determine its class names. To avoid re-parsing the same file repeatedly (a component with many styles.x accesses, and both rules running over the same file), parsed results are cached per file and reused across rules and files within a lint run. Each cache entry is automatically invalidated when the CSS file's modification time changes, so long-running editor/LSP sessions stay correct when a CSS file is edited between lint passes.
The cache is bounded by a small LRU to keep memory flat in long-lived sessions. The default holds 15 files, which comfortably covers typical single-file editor linting. Override it via the shared settings object if you run large batch lints and want more cross-file reuse:
// eslint.config.js
export default [
{
settings: {
'css-modules-next': { cacheSize: 15 },
},
},
];Set cacheSize: 0 to disable caching entirely (every lookup re-parses from disk). cacheSize is plugin-wide (shared by every rule) rather than a per-rule option, which is why it lives under settings. Non-numeric values are ignored. The cap only affects performance — neither eviction nor disabling changes lint results.
Caching is a meaningful speedup since parsing is the dominant cost and scales with the number of class accesses: linting a 150-class / 150-access component (both rules enabled) benchmarks at ~31 ms/lint without caching versus ~1.6–1.8 ms/lint with it — roughly a 17–19× improvement.
relativePaths (shared)
Type: boolean
Default: false
Controls how the CSS module path is rendered in diagnostic messages. By default, paths are reported as full absolute filesystem paths (e.g. /Users/you/project/src/Button.module.css), which some integrated terminals can resolve more reliably into clickable links. Set relativePaths: true to report a ./-prefixed path relative to the project root instead (e.g. ./src/Button.module.css), which keeps messages shorter and portable across machines:
// eslint.config.js
export default [
{
settings: {
'css-modules-next': { relativePaths: true },
},
},
];This affects message text only — it changes neither which classes are reported nor where the diagnostic is anchored. Only a strict boolean true enables it; any other value falls back to absolute paths.
Rules
Errors are surfaced inline in your editor when using the ESLint Extension:

css-modules-next/no-undefined-class
Severity in recommended: error
Reports when a CSS class is accessed via dot notation on a CSS module import but that class is not defined in the corresponding CSS file. Prevents runtime undefined values caused by typos or stale references.
/* Button.module.css */
.container { }
.label { }import styles from './Button.module.css';
<div className={styles.container} /> // OK
<div className={styles.missing} /> // Error: Class "missing" is not defined in CSS module "Button.module.css".Options
localsConvention
Type: "asIs" | "camelCase" | "camelCaseOnly"
Default: "asIs"
Controls which property names are considered valid when accessing CSS module classes. This should match the localsConvention setting in your CSS modules bundler configuration.
"asIs"— class names are exported exactly as written in CSS.styles["my-button"]is valid;styles.myButtonis not."camelCase"— both the original name and its camelCase equivalent are valid.styles["my-button"]andstyles.myButtonboth work."camelCaseOnly"— only the camelCase equivalent is valid.styles.myButtonworks;styles["my-button"]does not.
// eslint.config.js
export default [
{
rules: {
'css-modules-next/no-undefined-class': ['error', { localsConvention: 'camelCase' }],
},
},
];Prefer setting
localsConventiononce in shared settings so it stays in sync withno-unused-class. The per-rule option above overrides the shared value for this rule only.
css-modules-next/no-unused-class
Severity in recommended: error
Reports CSS classes that are defined in a CSS module file but never referenced in the importing TypeScript/JavaScript file. Helps keep CSS files free of dead code.
/* Button.module.css */
.container { }
.label { }
.deprecated { }import styles from './Button.module.css';
<div className={styles.container}> // OK
<span className={styles.label} /> // OK
</div>
// Error: Class "deprecated" in CSS module "Button.module.css:3" is never used in this fileOptions
localsConvention
Type: "asIs" | "camelCase" | "camelCaseOnly"
Default: "asIs"
Controls which property accesses count as "using" a CSS class. This should match the localsConvention setting in your CSS modules bundler configuration.
"asIs"— a class is considered used only if its original name is accessed.styles.myButtondoes not satisfy.my-button."camelCase"— a class is considered used if either its original name or its camelCase equivalent is accessed. Bothstyles["my-button"]andstyles.myButtonsatisfy.my-button."camelCaseOnly"— a class is considered used only if its camelCase equivalent is accessed.styles.myButtonsatisfies.my-button;styles["my-button"]does not.
// eslint.config.js
export default [
{
rules: {
'css-modules-next/no-unused-class': ['error', { localsConvention: 'camelCase' }],
},
},
];Prefer setting
localsConventiononce in shared settings so it stays in sync withno-undefined-class. The per-rule option above overrides the shared value for this rule only.
css-modules-next/invalid-css-module-filepath
Severity in recommended: error
Enforces that a CSS module file is co-located in the same directory as its importing file and shares the same base name. This keeps stylesheets easy to find and makes the relationship between a component and its styles unambiguous.
src/
Button.tsx
Button.module.css <- correct
Card.module.css <- wrong base name
styles/
Button.module.css <- wrong directory// In Button.tsx:
import styles from './Button.module.css'; // OK
import styles from './Card.module.css'; // Error: wrong base name
import styles from '../styles/Button.module.css'; // Error: not co-locatedSharing styles between components
The co-location rule intentionally prevents a single CSS module from being consumed by multiple files. When common base styles need to be shared — a button reset, a spacing utility, a theme mixin — reach for the composes keyword rather than importing from a shared module:
/* shared.css (a plain CSS file, not a module) */
.baseButton {
padding: 8px 16px;
border-radius: 4px;
border: none;
cursor: pointer;
}/* Button.module.css */
.primary {
composes: baseButton from './shared.css';
background: blue;
color: white;
}
.secondary {
composes: baseButton from './shared.css';
background: transparent;
border: 1px solid blue;
}The shared styles are defined once; each component module composes from them without the plugin needing to track cross-file dependencies.
Note: If you disable
invalid-css-module-filepathand import the same CSS module into multiple files, the accuracy ofno-unused-classwill degrade. The plugin checks each importing file in isolation — a class that goes unreferenced in one file will be reported as unused even if it is used in another. Co-location is what makes the one-to-one relationship between a CSS module and its consumer reliable enough to enforce unused class detection.
css-modules-next/no-dynamic-class-access
Severity in recommended: error
Disallows computed property access on CSS module imports where the key is not a static literal. This covers variable lookups, function calls, and template literals with interpolations.
Static string literals in bracket notation (styles['container']) are allowed — the class name is known at analysis time and can be verified the same way as dot notation.
import styles from './Button.module.css';
styles.container // OK — dot notation
styles['container'] // OK — static string literal
styles[name] // Error — variable
styles[getClass()] // Error — function call
styles[`${x}Button`] // Error — template literal with interpolationWhy dynamic access is banned
When the property key is a runtime value, there is no way to know which class names will actually be accessed without executing the code. That makes it impossible to verify that the accessed class exists in the CSS file, or to detect which CSS classes are unused. The two core rules of this plugin both rely on being able to enumerate every class access statically.
Preferred alternative: use a switch statement (or an explicit map) to convert the dynamic value into a known class reference:
function getButtonClass(variant: 'primary' | 'secondary' | 'ghost') {
switch (variant) {
case 'primary': return styles.primaryButton;
case 'secondary': return styles.secondaryButton;
case 'ghost': return styles.ghostButton;
}
}Every branch references a class name that the plugin can verify statically, so typos and stale references are still caught.
License
MIT
