mrkeyoor.com_
Thu 06 Aug 15:42 UTC
npmWeb Frontendupdated 06 Aug 2026

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.

Verdict

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.

API stability3/5The rule names and config shape have been stable for years, but the last two majors both cost real migration work: 16.0.0 removed every stylistic rule, and 17.0.0 dropped the CommonJS Node.js API, removed the output property from lint results, deleted the GitHub formatter, and raised the Node floor to 20.19.0. Each break is documented in a per-version migration guide with diffs, which is more care than most linters take, but 'extend the standard config and forget about it' is not the experience
Docs5/5stylelint.io has a page per rule with the problematic and correct patterns spelled out as actual CSS, complete option docs for the CLI and Node API, separate developer guides for plugins, custom syntaxes, and formatters, and a written migration guide for every major back to 14. The rule pages are the reason config triage is survivable
Maintenance4/517.14.1 shipped 2026-07-20 with a steady minor roughly monthly, repo pushed 2026-08-05, and 130 open issues out of 134 open issues and PRs. It is explicitly volunteer-maintained and funded through Open Collective, so response times vary and large feature requests sit, but the release cadence and the dependency cool-down policy suggest people who intend to keep doing this
Ecosystem4/5About 10.5M downloads a week, shareable configs for standard, SCSS, Less, Vue, and Tailwind, custom syntaxes covering every CSS dialect people actually write, editor plugins for VS Code and the JetBrains editors, and widely used plugins like stylelint-order. The gap is that most of that surface lives in separate packages maintained by different people, so a major Stylelint release can leave a plugin you depend on stranded for a while

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
Skip it if

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-disables

Always 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.json

Marked 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-staged

Do 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

PackageRegistryPick it when
@biomejs/biomenpmYou 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
prettiernpmYour 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/cssnpmYou 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