CSS specificity wars are the most common reason CSS codebases become unmaintainable. One developer needs to override a style and reaches for a more specific selector or !important. The next developer needs to override THAT, and the cycle escalates. Within a year you have selectors like body.page #wrapper .container .card .button and a forest of !important declarations. This guide walks through audit, refactor, CSS Layers, and CI enforcement. For related fixes, see the CSS Checker Fixes index.
CSS specificity is a four-component score, compared left-to-right:
Examples:
div /* (0,0,0,1) */ .button /* (0,0,1,0) */ .card .button /* (0,0,2,0) */ .card div.button /* (0,0,2,1) */ #header .nav a:hover /* (0,1,2,1) */ [data-state="active"] .button /* (0,0,2,0) */ :where(.card) .button /* (0,0,1,0) — :where adds 0 */
/* HIGH SPECIFICITY — hard to override */
#header { padding: 16px; }
#header .logo { width: 120px; }
/* FLAT — easier to override */
.header { padding: 16px; }
.header__logo { width: 120px; }
IDs as styling hooks were popular before classes were widespread. Today reserve IDs for label-for, fragment anchors, and JS hooks. Use classes for styling.
/* DEEP NESTING — fragile, high specificity */
.page .content .article .header .title { color: blue; }
/* FLAT — robust */
.article-title { color: blue; }
Sass / Less nesting can produce these accidentally. Limit nesting depth — most codebases benefit from max 3 levels.
/* Each layer adds !important to overrride previous --> */
.button { background: blue !important; }
.button.primary { background: red !important; }
.modal .button { background: green !important; }
/* New requirement: nobody can override anything without ALSO using !important */
/* FIX — restart with low-specificity selectors, no !important */
.button { background: blue; }
.button--primary { background: red; }
.modal .button { background: green; }
/* MIXED */
.button { padding: 12px; }
button.primary { padding: 16px; }
/* CONSISTENT — class-only */
.button { padding: 12px; }
.button--primary { padding: 16px; }
Single class per rule. Specificity stays flat at (0,0,1,0) for almost everything.
.card { ... } /* Block */
.card__title { ... } /* Element */
.card__title--large { ... } /* Modifier */
.card--featured { ... } /* Block modifier */
No specificity wars because there's no per-page CSS. Components compose utilities directly in HTML.
<div class="p-4 bg-blue-500 text-white rounded">...</div>
Class names get auto-hashed per file, so collisions impossible. Component-scoped styles by construction.
// Card.module.css
.card { ... }
.title { ... }
// Card.jsx
import styles from './Card.module.css';
<div className={styles.card}>
<h3 className={styles.title}>...</h3>
</div>
// Generated HTML: <div class="Card_card_abc123">
The modern way to control cascade without specificity wars. All modern browsers support it since 2022.
@layer reset, base, components, utilities;
@layer reset {
* { margin: 0; padding: 0; }
}
@layer base {
body { font-family: system-ui; line-height: 1.5; }
}
@layer components {
.button { padding: 12px; }
.card { background: white; }
}
@layer utilities {
.text-center { text-align: center; }
.hidden { display: none; }
}
Key insight: rules in utilities always win over rules in components, regardless of specificity. .hidden (a single class) beats .modal .card .button (three classes) because utility layer comes after components layer.
Result: you can confidently use simple utilities to override complex component styles without resorting to !important.
// .stylelintrc.json
{
"extends": "stylelint-config-standard",
"rules": {
"selector-max-specificity": "0,3,0",
"selector-max-id": 0,
"selector-max-compound-selectors": 3,
"declaration-no-important": true
}
}
Blocks new high-specificity selectors and !important. Existing violations need eslint-disable-style comments while you migrate.
Sometimes you genuinely want a complex selector but don't want its specificity. :where() wraps selectors with zero specificity:
/* Specificity (0,0,3,0) — three classes */
.card .header .title { color: blue; }
/* Specificity (0,0,1,0) — :where() adds zero, only .title counts */
:where(.card, .modal, .panel) .title { color: blue; }
/* :is() takes the highest specificity of its arguments */
:is(.card, #special) .title { ... } /* :is() contributes (0,1,0,0) here */
:where() is excellent for shared selectors in design systems — lets you write expressive selectors without specificity creep.