HTML and CSS course ยท Module 9: Modern CSS
Stylelint - A Linter for CSS
In this lesson6
In a team of five builders, everyone writes CSS their own way: one pastes the same selector twice, another nests styles seven levels deep. Code review turns into an argument about trifles, while real errors slip through unnoticed. Stylelint is a powerful static analysis tool for CSS code, which means it checks the code without running the page. It helps maintain consistency, detect errors and enforce best practices in stylesheets.
Stylelint Basics
Installation and Configuration
You install Stylelint as a development dependency of the project, together with a ready-made rule set and optional plugins:
# Basic installation
npm install --save-dev stylelint stylelint-config-standard
# With additional plugins
npm install --save-dev stylelint-scss stylelint-order stylelint-config-prettierThe stylelint-config-standard package is a sensible rule set to start with, stylelint-scss teaches Stylelint the SCSS syntax, and stylelint-order keeps the order of properties. The stylelint-config-prettier package has been unnecessary since Stylelint 15.
Basic Configuration File .stylelintrc.json
You write the configuration in the .stylelintrc.json file. The extends field loads a ready-made set, plugins adds plugins, and rules turns individual rules on, off or tunes them:
{
"extends": [
"stylelint-config-standard"
],
"plugins": [
"stylelint-order",
"stylelint-scss"
],
"rules": {
"indentation": 2,
"string-quotes": "single",
"no-duplicate-selectors": true,
"color-hex-case": "lower",
"color-hex-length": "short",
"color-named": "never",
"selector-combinator-space-after": "always",
"selector-combinator-space-before": "always",
"selector-pseudo-element-colon-notation": "double",
"selector-pseudo-class-parentheses-space-inside": "never",
"selector-no-qualifying-type": true,
"property-no-unknown": true,
"keyframe-declaration-no-important": true,
"declaration-block-trailing-semicolon": "always",
"declaration-no-important": true,
"declaration-block-single-line-max-declarations": 1,
"selector-class-pattern": "^[a-z0-9\\-]+$",
"selector-id-pattern": "^[a-z0-9\\-]+$",
"custom-property-pattern": "^[a-z0-9\\-]+$",
"max-nesting-depth": 3,
"order/order": [
"custom-properties",
"declarations"
],
"order/properties-order": [
"position",
"top",
"right",
"bottom",
"left",
"z-index",
"display",
"flex",
"flex-direction",
"flex-wrap",
"justify-content",
"align-items",
"width",
"height",
"margin",
"padding",
"background",
"color",
"font-family",
"font-size",
"line-height",
"border",
"border-radius",
"transition",
"transform"
]
}
}An important note: purely stylistic rules, such as indentation, string-quotes, color-hex-case or declaration-block-trailing-semicolon, were deprecated in Stylelint 15 and removed in version 16, which reports them as unknown. Remove them or move them to the @stylistic/stylelint-plugin plugin, and leave formatting to Prettier. Rules that detect errors, such as no-duplicate-selectors, work unchanged.
Extended Configuration for Larger Projects
A larger project adds plugins for naming conventions, enforced variables and separate rules for different file types:
{
"extends": [
"stylelint-config-standard",
"stylelint-config-prettier"
],
"plugins": [
"stylelint-order",
"stylelint-scss",
"stylelint-selector-bem-pattern",
"stylelint-declaration-use-variable"
],
"rules": {
"plugin/selector-bem-pattern": {
"preset": "bem",
"presetOptions": {
"namespace": "c-"
}
},
"sh-waqar/declaration-use-variable": [
[
"color",
"background-color",
"border-color",
"font-family",
"font-size",
"z-index"
]
],
"color-function-notation": "modern",
"alpha-value-notation": "percentage",
"color-hex-alpha": "never",
"font-family-name-quotes": "always-where-recommended",
"function-url-quotes": "always",
"import-notation": "string",
"keyframe-selector-notation": "percentage-unless-within-keyword-only-block",
"media-feature-range-notation": "context",
"selector-not-notation": "complex",
"at-rule-no-vendor-prefix": true,
"media-feature-name-no-vendor-prefix": true,
"property-no-vendor-prefix": true,
"selector-no-vendor-prefix": true,
"value-no-vendor-prefix": true,
"max-line-length": 120,
"no-empty-source": null,
"declaration-block-no-redundant-longhand-properties": true,
"shorthand-property-no-redundant-values": true,
"comment-empty-line-before": [
"always",
{
"except": ["first-nested"],
"ignore": ["stylelint-commands"]
}
],
"custom-property-empty-line-before": [
"always",
{
"except": ["after-custom-property", "first-nested"],
"ignore": ["after-comment", "inside-single-line-block"]
}
],
"declaration-empty-line-before": [
"always",
{
"except": ["after-declaration", "first-nested"],
"ignore": ["after-comment", "inside-single-line-block"]
}
]
},
"overrides": [
{
"files": ["*.scss", "**/*.scss"],
"customSyntax": "postcss-scss",
"rules": {
"scss/at-rule-no-unknown": true,
"scss/selector-no-redundant-nesting-selector": true,
"scss/no-duplicate-dollar-variables": true,
"scss/dollar-variable-pattern": "^[a-z0-9\\-]+$",
"scss/percent-placeholder-pattern": "^[a-z0-9\\-]+$"
}
},
{
"files": ["*.vue", "**/*.vue"],
"customSyntax": "postcss-html"
}
]
}The overrides section applies extra rules to the listed files, and customSyntax lets Stylelint read SCSS or Vue files. Here too, remove the deprecated max-line-length rule.
Integration with Developer Tools
Package.json Scripts
You run Stylelint with scripts in package.json, so the whole team uses the same commands:
{
"scripts": {
"lint:css": "stylelint \"src/**/*.{css,scss}\"",
"lint:css:fix": "stylelint \"src/**/*.{css,scss}\" --fix",
"lint:css:report": "stylelint \"src/**/*.{css,scss}\" --formatter json > stylelint-report.json",
"prebuild": "npm run lint:css",
"test:css": "stylelint \"src/**/*.{css,scss}\" --formatter tap"
}
}The npm run lint:css command only checks the files, while lint:css:fix with the --fix flag automatically fixes the problems the tool can repair by itself. npm runs the prebuild script before every build.
VS Code Integration (.vscode/settings.json)
The official Stylelint extension for VS Code underlines problems right as you type, and these settings configure it:
{
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.stylelint": true
},
"css.validate": false,
"less.validate": false,
"scss.validate": false,
"stylelint.validate": ["css", "postcss", "scss", "sass"],
"stylelint.snippet": ["css", "postcss", "scss", "sass"],
"stylelint.enable": true
}The source.fixAll.stylelint setting fixes the file on every save, and turning off the built-in CSS validation prevents duplicate messages.
Pre-commit Hook with Husky
The last line of defense is checking files right before a commit, so that faulty code does not reach the repository:
# Install husky and lint-staged
npm install --save-dev husky lint-staged
# package.json
{
"lint-staged": {
"*.{css,scss}": [
"stylelint --fix",
"git add"
]
},
"husky": {
"hooks": {
"pre-commit": "lint-staged"
}
}
}This notation is for older versions of the tools. In current Husky you create the hook with the npx husky init command, which sets up the .husky/pre-commit file, and since version 10 lint-staged adds the fixed files by itself, so git add is unnecessary.
Rule Examples and Their Applications
1. Formatting and Consistency
Let's see what problems the linter catches. First the code with errors, then its corrected version:
/* Errors detected by Stylelint */
/* Incorrect indentation */
.component {
margin: 0;
padding: 1rem;
}
/* Missing spaces */
.component{margin:0;padding:1rem;}
/* Incorrect quotes */
.component {
font-family: "Arial";
background: url(image.jpg);
}
/* Incorrect colors */
.component {
color: RED;
background: #ffffff;
border: 1px solid #fff;
}
/* Corrected code following the rules */
/* Correct indentation */
.component {
margin: 0;
padding: 1rem;
}
/* Correct spaces */
.component {
margin: 0;
padding: 1rem;
}
/* Correct quotes */
.component {
font-family: 'Arial', sans-serif;
background: url('image.jpg');
}
/* Correct colors */
.component {
color: #f00;
background: #fff;
border: 1px solid #fff;
}Today Prettier fixes the indentation, but Stylelint still catches, for example, named colors or #ffffff written instead of #fff.
2. Property Order
The stylelint-order plugin makes sure properties always stand in the order set in the configuration:
/* Incorrect order */
.component {
color: #333;
position: absolute;
background: #fff;
top: 0;
margin: 1rem;
display: flex;
left: 0;
padding: 1rem;
}
/* Corrected order following configuration */
.component {
position: absolute;
top: 0;
left: 0;
display: flex;
margin: 1rem;
padding: 1rem;
background: #fff;
color: #333;
}
/* Even better organization with grouping */
.component {
/* Positioning */
position: absolute;
top: 0;
left: 0;
z-index: 1;
/* Layout */
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
/* Box model */
width: 100%;
height: 200px;
margin: 1rem;
padding: 1rem;
/* Visual */
background: #fff;
color: #333;
border: 1px solid #ddd;
border-radius: 4px;
/* Typography */
font-family: 'Inter', sans-serif;
font-size: 1rem;
line-height: 1.5;
/* Animation */
transition: all 0.3s ease;
transform: translateY(0);
}With the --fix flag Stylelint reorders the declarations itself. The look of the page does not change, only readability improves.
3. BEM Pattern Validation
BEM is a naming convention: a block (.card), an element after two underscores (.card__title) and a modifier after two hyphens (.card--featured). The BEM plugin checks whether classes follow this pattern:
/* Incorrect BEM class names */
.Component { } /* PascalCase */
.component_element { } /* single underscore */
.component-element-modifier { } /* dash instead of -- */
.componentElement { } /* camelCase */
/* Correct BEM class names */
.component { }
.component__element { }
.component__element--modifier { }
.component--variant { }
.multi-word-component { }
.multi-word-component__element-name { }
.multi-word-component__element-name--modifier-name { }So .component__element--modifier is correct, while a single underscore, PascalCase and camelCase are wrong.
4. SCSS Specific Rules
For SCSS, the stylelint-scss plugin checks the names of variables, placeholders and mixins, and the max-nesting-depth rule catches nesting that goes too deep:
/* Incorrect SCSS */
$primaryColor: #007bff; /* camelCase variable */
$primary_color: #007bff; /* underscore variable */
%button-style { /* unused placeholder */
padding: 1rem;
}
.component {
.nested {
.deep {
.too {
.deep { /* too deeply nested */
color: red;
}
}
}
}
}
@mixin buttonMixin() { /* camelCase mixin */
padding: 1rem;
}
/* Correct SCSS */
$primary-color: #007bff;
$secondary-color: #6c757d;
%button-base {
padding: 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
}
.component {
@include button-mixin;
&__element {
color: $primary-color;
&--modifier {
background: $secondary-color;
}
}
}
@mixin button-mixin($size: medium) {
@extend %button-base;
@if $size == small {
padding: 0.5rem;
} @else if $size == large {
padding: 1.5rem;
}
}Variables and mixins written in kebab-case, such as $primary-color and button-mixin, match the names of CSS properties.
Advanced Configuration
Custom Rules for Specific Projects
Rules ending in -pattern accept regular expressions, and lists of allowed values let you narrow CSS down to the needs of the project:
{
"rules": {
"comment-pattern": "^[A-Z][\\s\\S]*\\.$",
"custom-property-pattern": "^([a-z][a-z0-9]*)(-[a-z0-9]+)*$",
"keyframes-name-pattern": "^([a-z][a-z0-9]*)(-[a-z0-9]+)*$",
"selector-class-pattern": [
"^[a-z]([a-z0-9-]+)?(__([a-z0-9]+-?)+)?(--([a-z0-9]+-?)+){0,2}$",
{
"message": "Expected class selector to be kebab-case BEM format"
}
],
"declaration-property-value-allowed-list": {
"border": ["/^0$/"],
"border-top": ["/^0$/"],
"border-right": ["/^0$/"],
"border-bottom": ["/^0$/"],
"border-left": ["/^0$/"]
},
"property-allowed-list": [
"position",
"top",
"right",
"bottom",
"left",
"z-index",
"display",
"flex",
"flex-direction",
"flex-wrap",
"justify-content",
"align-items",
"align-content",
"gap",
"width",
"height",
"min-width",
"min-height",
"max-width",
"max-height",
"margin",
"margin-top",
"margin-right",
"margin-bottom",
"margin-left",
"padding",
"padding-top",
"padding-right",
"padding-bottom",
"padding-left",
"background",
"background-color",
"background-image",
"background-size",
"background-position",
"background-repeat",
"color",
"font-family",
"font-size",
"font-weight",
"line-height",
"text-align",
"text-decoration",
"text-transform",
"border",
"border-top",
"border-right",
"border-bottom",
"border-left",
"border-radius",
"box-shadow",
"opacity",
"visibility",
"overflow",
"overflow-x",
"overflow-y",
"cursor",
"transition",
"transform",
"animation"
]
}
}property-allowed-list allows only the listed properties, so it is a very strict rule, good for design systems.
Performance-Focused Rules
These rules limit the complexity of selectors: the number of IDs, classes and nesting levels, as well as specificity:
{
"rules": {
"selector-max-id": 0,
"selector-max-universal": 1,
"selector-max-type": 2,
"selector-max-class": 4,
"selector-max-attribute": 2,
"selector-max-pseudo-class": 3,
"selector-max-compound-selectors": 4,
"selector-max-specificity": "0,4,0",
"max-nesting-depth": 3,
"declaration-block-no-redundant-longhand-properties": true,
"shorthand-property-no-redundant-values": true,
"no-unknown-animations": true
}
}selector-max-id: 0 bans IDs in selectors, and selector-max-specificity sets an upper limit.
Integration with Build Tools
Webpack Integration
You can also plug Stylelint into a build tool and see the errors in the console while building:
// webpack.config.js
const StylelintPlugin = require('stylelint-webpack-plugin');
module.exports = {
plugins: [
new StylelintPlugin({
configFile: '.stylelintrc.json',
context: 'src',
files: '**/*.{css,scss}',
failOnError: false,
quiet: false,
emitErrors: true
})
]
};With failOnError: false errors are reported but do not stop the build.
Gulp Integration
In projects based on Gulp, the linter is a separate task, next to a task that fixes the files:
// gulpfile.js
const gulp = require('gulp');
const stylelint = require('gulp-stylelint');
gulp.task('lint-css', function() {
return gulp
.src('src/**/*.{css,scss}')
.pipe(stylelint({
reporters: [
{ formatter: 'string', console: true }
]
}));
});
gulp.task('fix-css', function() {
return gulp
.src('src/**/*.{css,scss}')
.pipe(stylelint({
fix: true
}))
.pipe(gulp.dest('src'));
});The fix-css task writes the fixed files back to the src directory.
PostCSS Integration
Stylelint also works as a PostCSS plugin. The order of plugins matters, because the linter has to see the code before it is transformed:
// postcss.config.js
module.exports = {
plugins: [
require('stylelint')({
config: require('./.stylelintrc.json')
}),
require('autoprefixer'),
require('cssnano')({
preset: 'default'
})
]
};Stylelint checks the source, Autoprefixer adds prefixes, and cssnano minifies the result at the end.
CI/CD Integration
GitHub Actions
On a continuous integration server, linting runs on every push and pull request:
# .github/workflows/css-lint.yml
name: CSS Lint
on: [push, pull_request]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Install dependencies
run: npm ci
- name: Run Stylelint
run: npm run lint:css
- name: Upload lint results
uses: actions/upload-artifact@v4
if: failure()
with:
name: stylelint-results
path: stylelint-report.jsonAction versions change from time to time, so check them in the GitHub Actions documentation.
Custom Reporting
With the Node.js API you can prepare your own summary, for example the number of errors per rule:
// scripts/css-report.js
const stylelint = require('stylelint');
const fs = require('fs');
stylelint.lint({
files: 'src/**/*.{css,scss}',
formatter: 'json'
}).then(function(resultObject) {
const results = JSON.parse(resultObject.output);
const summary = {
totalFiles: results.length,
totalErrors: results.reduce((sum, file) => sum + file.warnings.filter((w) => w.severity === 'error').length, 0),
errorsByRule: {}
};
results.forEach(file => {
file.warnings.forEach(warning => {
const rule = warning.rule;
summary.errorsByRule[rule] = (summary.errorsByRule[rule] || 0) + 1;
});
});
console.log('CSS Lint Summary:');
console.log(`Total files analyzed: ${summary.totalFiles}`);
console.log(`Total errors found: ${summary.totalErrors}`);
console.log('Errors by rule:');
Object.entries(summary.errorsByRule)
.sort(([,a], [,b]) => b - a)
.forEach(([rule, count]) => {
console.log(` ${rule}: ${count}`);
});
fs.writeFileSync('css-lint-summary.json', JSON.stringify(summary, null, 2));
});The script counts warnings with the error severity, groups them by rule name and saves the report to a JSON file.
Stylelint is an essential tool in professional CSS development, and a proper configuration significantly improves the quality and maintainability of styles. My advice: roll it out in four steps, installing the packages, creating the .stylelintrc.json file, adding a lint:css script to package.json and running it for the first time. In the next lesson we will check how your styles look in different browsers.
Remember: Stylelint is the construction overseer who inspects every stone block before it goes onto the pyramid.
Code for this lesson: index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>:is() and :where()</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<main>
<article>
<h2>An article about pyramids</h2>
<p>Text with a <a href="#">link</a>.</p>
</article>
<section>
<h2>A section about pharaohs</h2>
<p>Text with a <a href="#">link</a>.</p>
</section>
<nav>
<h2>Navigation</h2>
<ul>
<li><a href="#">Link 1</a></li>
<li><a href="#">Link 2</a></li>
</ul>
</nav>
</main>
</body>
</html>Docs entries used in this lesson
Spotted a mistake in this lesson?
Check yourself
Answer the questions from this lesson. Pick an answer to see right away whether it is correct.
1. What is Stylelint?
2. What does the --fix flag do when run with Stylelint?
These are 2 of 3 questions for this lesson. Solve the rest in the game.
Hands-on tasks in the game
- Vertical ordering
Arrange the steps of adding Stylelint to a project in the correct order: