HTML and CSS course ยท Module 9: Modern CSS

Stylelint - A Linter for CSS

11 min read ยท
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-prettier

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

Action 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. 1. What is Stylelint?

  2. 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:

Useful articles