stylelint
Stylelint parses your stylesheets with PostCSS and checks them against rules you turn on. It ships over 100 built-in rules doing two different jobs: catching mistakes (unknown property names, malformed grid areas, duplicate selectors, invalid hex colors) and enforcing conventions (naming patterns for classes and custom properties, which units are allowed, modern versus legacy color notation, limits on ID selectors). No rules are enabled by default, so a fresh install reports nothing until you extend a shareable config such as stylelint-config-standard or list rules yourself. It reads plain CSS directly and handles SCSS, Less, Sass, SugarSS, CSS-in-JS template literals, and styles embedded in HTML or Markdown through separate custom syntax packages. A good share of the rules can repair their own violations with --fix.
Still the only serious linter for real CSS and SCSS, and the autofixable modern-syntax rules pay for the setup on their own. Budget an afternoon for the initial config triage, and do not install it if your styles are Tailwind classes in JSX.
Use it if
- You have hand-written CSS or SCSS in a repo with more than one author and want misspelled properties, duplicate selectors, and invalid values to fail CI instead of shipping
- You want naming rules enforced mechanically: selector-class-pattern and custom-property-pattern take a regex, so BEM or kebab-case stops being a code review argument
- You are moving a codebase toward modern CSS and want the linter to push it: color-function-notation, alpha-value-notation, media-feature-range-notation, and the *-no-vendor-prefix rules autofix most of the mechanical work
- You already run PostCSS in your build, so the parser and the plugin ecosystem are things you have anyway
- You need to enforce a rule on new code while an established codebase still violates it, which is what the experimental --suppress flag and its suppressions file are for
- Your styling is Tailwind utility classes or a CSS-in-TS library that emits CSS at build time. Stylelint sees a className string in JSX as a string, not as CSS, so it checks almost nothing and you are configuring a tool that has no input
- What you actually want is formatting. Every stylistic rule (indentation, quotes, whitespace around blocks) was deprecated in 15.0.0 and removed in 16.0.0. Those live in @stylistic/stylelint-plugin now, which is maintained separately, and the project's own recommendation is to run Prettier for layout and Stylelint only for rules
- You care about lint wall-clock time. This is Node plus PostCSS parsing every file, and on a large SCSS tree a full run is measured in tens of seconds rather than the sub-second Rust linters manage. TIMING=10 will show you which rules cost the most, and no-descending-specificity is usually near the top
- You are on a CommonJS toolchain. Version 17 is ESM only: the CommonJS Node.js API is gone, require('stylelint') no longer works, and the minimum Node version is 20.19.0. A CJS project has to use dynamic import or stay on 16
- You want a config you can adopt in an afternoon. Over 100 rules, most with a primary option and a bag of secondary options, means the honest path is extend a shareable config, run it, and then spend a few hours deciding which of the resulting hundreds of problems your team actually cares about
- You need one tool for JavaScript and CSS. Stylelint deliberately does only CSS, so you are running it next to ESLint or Biome anyway, which means two configs, two caches, and two CI steps
Setup reality
npm install --save-dev stylelint stylelint-config-standard, then create a stylelint.config.js that exports a config object. Version 17 needs Node 20.19.0 or newer and is ESM only, so the config file is export default rather than module.exports unless you name it stylelint.config.cjs. Installing Stylelint alone is a no-op: no rules are on by default, so the first run finds zero problems and people conclude it is broken. You need the extends line. Non-CSS syntaxes are all extra packages: SCSS wants stylelint-config-standard-scss (which pulls postcss-scss), Vue and HTML want postcss-html as customSyntax, Less wants postcss-less. Each one is a separate install plus an overrides block keyed on file glob, because customSyntax is global to a run unless you scope it. The dependency tree is wide by design (postcss, css-tree, cosmiconfig, globby, and about thirty more), so expect a visible chunk of node_modules. Always quote your globs in package.json scripts, and turn on --cache early because a second run over an unchanged tree should be nearly free and without it is not. If you also run Prettier, install nothing extra: the old stylelint-config-prettier package is unnecessary since the stylistic rules were removed in 16.
Patterns
Install, then actually turn rules oninstall-and-first-config
npm install --save-dev stylelint stylelint-config-standard
# stylelint.config.js (ESM: Stylelint 17 has no CommonJS API)
/** @type {import('stylelint').Config} */
export default {
extends: ["stylelint-config-standard"],
rules: {
"selector-class-pattern": "^[a-z][a-z0-9]*(-[a-z0-9]+)*$",
"declaration-no-important": true,
"block-no-empty": null // null turns a rule off
}
};
# run it (quote the glob)
npx stylelint "src/**/*.css"Without the extends line Stylelint reports nothing at all, because no rules are enabled by default. That is the single most common 'it does not work' report. If your package.json has no "type": "module", name the file stylelint.config.mjs or stylelint.config.cjs so Node picks the right module system. A rule value of null turns it off, true turns it on with defaults, and an array is [primary, secondaryOptions].
CLI flags that matter: fix, cache, max-warningsrun-in-ci-and-fix
{
"scripts": {
"lint:css": "stylelint \"src/**/*.{css,scss}\" --cache",
"lint:css:fix": "stylelint \"src/**/*.{css,scss}\" --fix --cache"
}
}
# CI: fail on any warning, not just errors
stylelint "src/**/*.css" --max-warnings 0
# don't explode when a glob matches nothing (monorepo packages with no CSS)
stylelint "src/**/*.css" --allow-empty-input
# which rules are eating the clock
TIMING=10 stylelint "src/**/*.css"Quote the globs, and escape the quotes inside package.json, or your shell expands them and Stylelint only sees the top-level matches. --cache writes .stylelintcache in cwd and makes repeat runs nearly free, so add it to .gitignore. In 17 the default fix mode is "strict", which refuses to fix a file that has a syntax error; pass fix: "lax" in the config if you want postcss-safe-parser to patch what it can anyway.
SCSS, Less, and Vue need extra packages plus overridesscss-and-other-syntaxes
npm i -D stylelint-config-standard-scss postcss-html
export default {
extends: ["stylelint-config-standard"],
overrides: [
{
files: ["**/*.scss"],
extends: ["stylelint-config-standard-scss"]
},
{
files: ["**/*.{vue,html}"],
customSyntax: "postcss-html",
rules: {
"selector-pseudo-class-no-unknown": [true, { ignorePseudoClasses: ["deep", "global"] }]
}
}
]
};customSyntax applies to the whole run unless you scope it inside an overrides entry, so a single top-level customSyntax: "postcss-scss" will make Stylelint try to parse your .vue files as SCSS. Each dialect is a separate npm install; none of them ship with Stylelint. Vue scoped styles use ::v-deep and :deep(), which the standard config flags as unknown pseudo-classes until you add the ignore list.
Turn rules off inline, and find the comments you no longer needdisable-comments
/* stylelint-disable-next-line declaration-no-important -- vendor widget wins otherwise */
.thirdparty { color: red !important; }
/* stylelint-disable selector-max-id */
#legacy-root .a {}
#legacy-root .b {}
/* stylelint-enable selector-max-id */
/* one line, trailing */
.x { color: #FFF; } /* stylelint-disable-line color-hex-length */
# audit them
stylelint "src/**/*.css" --report-needless-disables --report-descriptionless-disables --report-unscoped-disablesAlways name the rule after the disable comment. A bare /* stylelint-disable */ switches off everything for the rest of the file, and --report-unscoped-disables exists specifically because people do this. The -- text after the rule name is the reason string; --report-descriptionless-disables turns a missing reason into a reported problem. --report-needless-disables finds comments suppressing problems that no longer exist, which is how you clean up after a refactor.
Programmatic linting (the 17 result shape)node-api
import stylelint from "stylelint";
const result = await stylelint.lint({
code: "a { color: #FFFFFF }",
codeFilename: "virtual.css",
config: { rules: { "color-hex-length": "short" } },
fix: true
});
result.errored; // boolean
result.code; // fixed source, only when fix + code are used
result.report; // formatted text (was `output` before 17)
result.results; // per-file objects with .warnings[]
for (const w of result.results[0].warnings) {
console.log(w.line, w.column, w.rule, w.severity, w.text);
}The `output` property was deprecated in 16 and removed in 17: use `report` for the formatted string and `code` for the fixed source. You must pass exactly one of `code` or `files`, never both. Passing `config` skips the config file search entirely, which is what you want in a test but not in a CLI wrapper. Version 17 is ESM only, so require('stylelint') throws; from CommonJS you need await import('stylelint').
Enforce a rule on new code without fixing the whole reposuppress-legacy-problems
# record every current violation of one rule, then enforce it going forward
stylelint "src/**/*.css" --fix --suppress no-descending-specificity
# creates stylelint-suppressions.json - commit this file
git add stylelint-suppressions.json
# every later run must point at the same file
stylelint "src/**/*.css" --suppress-location stylelint-suppressions.jsonMarked experimental in the docs and the format may change, so treat it as a migration aid rather than a permanent fixture. Two sharp edges: --suppress-location has to be repeated on every subsequent run even when you are not suppressing anything new, and --suppress cannot be combined with stdin input. Suppression is per rule per file, so adding a new violation of an already-suppressed rule in an already-suppressed file still gets reported.
Skipping files and vendor CSSignore-files
# .stylelintignore (gitignore syntax, relative to cwd)
dist/
coverage/
src/vendor/**/*.css
*.min.css
# or inline in the config
export default {
extends: ["stylelint-config-standard"],
ignoreFiles: ["**/*.min.css", "src/generated/**"]
};
# one-off
stylelint "src/**/*.css" --ignore-pattern "src/legacy/**"node_modules is ignored automatically unless you pass --disable-default-ignores. ignoreFiles in the config is replaced, not merged, by an overrides block, and paths there are resolved relative to the config file's directory while .stylelintignore is relative to cwd, which trips people up in monorepos. Minified CSS is worth ignoring explicitly: it parses fine and then produces thousands of unfixable problems.
Adding a plugin, and writing a tiny oneplugins
npm i -D stylelint-order
export default {
plugins: ["stylelint-order"],
rules: {
"order/properties-alphabetical-order": true
}
};
// a minimal custom rule: my-plugin.js
import stylelint from "stylelint";
const ruleName = "my/no-px-font-size";
const messages = stylelint.utils.ruleMessages(ruleName, {
rejected: "Use rem for font-size, not px"
});
const rule = () => (root, result) => {
root.walkDecls(/^font-size$/, (decl) => {
if (!decl.value.endsWith("px")) return;
stylelint.utils.report({ message: messages.rejected, node: decl, result, ruleName });
});
};
rule.ruleName = ruleName;
rule.messages = messages;
export default stylelint.createPlugin(ruleName, rule);Plugin rule names must be namespaced with a slash or Stylelint rejects them. In 17 a local plugin path has to be the full file path: plugins: ["./my-plugin.js"], not ["./my-plugin"], because the CommonJS resolution that let you omit the extension is gone. Inside a rule you are working with the PostCSS AST directly, so root.walkDecls, walkRules, and walkAtRules are the whole API you need.
Lint only staged files with lint-stagedpre-commit-hook
npm i -D husky lint-staged
npx husky init
// package.json
{
"lint-staged": {
"*.{css,scss}": "stylelint --fix"
}
}
// .husky/pre-commit
npx lint-stagedDo not add a glob argument here: lint-staged appends the staged file paths itself, so "stylelint --fix" is correct and "stylelint 'src/**/*.css' --fix" would lint the whole tree on every commit. Skip --cache in a hook; the file list is already tiny and the cache just adds a write. Files fixed by the hook are re-staged by lint-staged automatically.
Warn instead of error, and say whyseverity-and-messages
export default {
extends: ["stylelint-config-standard"],
rules: {
// ship it as a warning while the team migrates
"color-function-notation": ["modern", { severity: "warning" }],
"custom-property-pattern": [
"^app-([a-z][a-z0-9]*)(-[a-z0-9]+)*$",
{ message: "Custom properties must start with --app-" }
],
// keep the rule but stop it rewriting your files
"shorthand-property-no-redundant-values": [true, { disableFix: true }]
},
defaultSeverity: "warning"
};severity: "warning" keeps a rule visible without failing the build, and --quiet then hides those warnings entirely so CI only sees errors. Pair it with --max-warnings 0 once the migration is done. disableFix is per rule and useful for rules whose autofix is technically correct but produces diffs nobody wants to review. defaultSeverity flips the default for every rule that does not set its own.
Find out which config is actually being applieddebug-the-resolved-config
# print the merged config for one file, after extends and overrides resolve
npx stylelint --print-config src/components/button.scss
# force a specific config and stop the upward search
npx stylelint "src/**/*.css" --config ./config/stylelint.strict.js
# relative extends/plugins paths resolving oddly? anchor them
npx stylelint "src/**/*.css" --config-basedir /abs/path/to/repo--print-config takes a single path, not a glob, and is the fastest way to answer 'why is this rule on'. Stylelint searches upward from cwd for stylelint.config.js, so in a monorepo a stray config in the root silently wins over the one you expected; --print-config shows you which one. The legacy .stylelintrc names and the stylelint key in package.json still work but the docs warn they may be removed.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| @biomejs/biome | npm | You want one fast Rust binary that formats and lints both JavaScript and CSS, and you can live with a much smaller CSS rule set and no SCSS or CSS-in-JS parsing |
| prettier | npm | Your team's real complaint is inconsistent formatting rather than incorrect CSS, in which case a formatter settles it and a linter is extra machinery |
| @eslint/css | npm | You already run ESLint flat config and would rather have one runner, one cache, and one CI step, accepting a rule set that is a fraction of Stylelint's |