Selectors and Specificity
The Selector You Write Decides More Than What Gets Styled
Every CSS selector carries a hidden weight — its specificity. Two rules targeting the same element with the same property? Specificity decides which one wins. And here's the thing most people miss: the majority of CSS bugs at scale aren't about wrong selectors. They're about selectors with unexpected specificity that silently override styles you expected to apply.
The developers who never fight CSS? They understand specificity deeply enough to write selectors with exactly the weight they intend.
Think of specificity as a three-digit combination lock. The first dial is IDs (heavyweight), the second is classes/attributes/pseudo-classes (middleweight), the third is elements/pseudo-elements (lightweight). You compare dial by dial from left to right — a higher first dial wins instantly, regardless of what the other dials say. No combination of lightweights can overpower a single heavyweight.
The Complete Selector Reference
Simple Selectors
/* Universal — matches everything, zero specificity */
* { margin: 0; }
/* Type/element — matches tag name */
p { color: #333; }
h1 { font-size: 2rem; }
/* Class — matches .className */
.card { padding: 1rem; }
/* ID — matches #identifier */
#hero { background: black; }
/* Attribute — matches [attr] or [attr=value] */
[type="email"] { border-color: blue; }
[data-active] { opacity: 1; }
Combinators
/* Descendant (space) — any depth */
.sidebar p { font-size: 0.9rem; }
/* Child (>) — direct children only */
.nav > li { display: inline-block; }
/* Adjacent sibling (+) — immediately after */
h2 + p { margin-top: 0; }
/* General sibling (~) — any sibling after */
h2 ~ p { color: #666; }
Combinators themselves add zero specificity — only the selectors within them contribute.
Pseudo-Classes and Pseudo-Elements
/* Pseudo-classes — (0, 1, 0) each */
a:hover { color: red; }
li:first-child { font-weight: bold; }
input:focus { outline: 2px solid blue; }
/* Pseudo-elements — (0, 0, 1) each */
p::first-line { font-weight: bold; }
.quote::before { content: open-quote; }
Specificity Calculation
Specificity is a tuple of three components: (IDs, Classes, Elements).
| Selector | IDs | Classes | Elements | Specificity |
|---|---|---|---|---|
p | 0 | 0 | 1 | (0,0,1) |
.card | 0 | 1 | 0 | (0,1,0) |
#hero | 1 | 0 | 0 | (1,0,0) |
p.card | 0 | 1 | 1 | (0,1,1) |
#hero .card p | 1 | 1 | 1 | (1,1,1) |
#hero #sidebar .card | 2 | 1 | 0 | (2,1,0) |
div.card[data-active]:hover | 0 | 3 | 1 | (0,3,1) |
What counts as what
ID column: #name selectors only. An [id="name"] attribute selector goes in the class column.
Class column: .class, [attribute], and pseudo-classes (:hover, :focus, :nth-child(), etc.)
Element column: p, div, h1, and pseudo-elements (::before, ::after, ::first-line)
Zero specificity: * (universal), combinators ( , >, +, ~), :where()
The Modern Specificity Tools: :is(), :where(), :not(), :has()
:where() -- Zero Specificity
Okay, this one is a game-changer. :where() matches exactly like :is(), but contributes zero specificity. That's right -- zero. This is revolutionary for writing overridable defaults:
/* Without :where() — specificity (0, 1, 1) */
.article p { color: #333; }
/* With :where() — specificity (0, 0, 0) */
:where(.article) p { color: #333; }
/* Now any class-level selector can override this */
/* Reset example — zero specificity means easy to override */
:where(h1, h2, h3, h4, h5, h6) {
margin-top: 0;
font-weight: 600;
}
:is() — Takes the Highest Specificity of Its Arguments
/* Without :is() — you'd write three selectors */
.card h2, .card h3, .card h4 { color: blue; }
/* With :is() — same result, cleaner syntax */
.card :is(h2, h3, h4) { color: blue; }
/* Specificity: (0, 1, 1) — .card + highest arg (element) */
The specificity of :is() equals the most specific selector in its argument list:
:is(.card, #hero, p) { color: red; }
/* Specificity: (1, 0, 0) — takes #hero's specificity */
/* Even when matching a <p>, this rule has ID-level weight */
Putting a high-specificity selector inside :is() inflates the specificity for ALL matches, not just the one that matched. :is(.card, #hero) p has specificity (1,0,1) even when it matches through .card p. This is a common source of unintended specificity escalation.
:not() — Same Specificity as :is()
:not() takes the specificity of its most specific argument:
/* (0, 1, 1) — :not(.active) contributes (0, 1, 0) + div (0, 0, 1) */
div:not(.active) { opacity: 0.5; }
:has() — The Parent Selector
:has() takes the specificity of its most specific argument:
/* (0, 1, 1) — .card contributes (0,1,0), :has(img) adds (0,0,1) */
.card:has(img) { padding: 0; }
/* (1, 1, 0) — :has(#featured) inherits the ID specificity */
.card:has(#featured) { border: 2px solid gold; }
The !important Escape Hatch
You know where this is going. !important promotes a declaration above all normal declarations. It jumps to a separate tier in the cascade:
.btn { color: blue !important; }
#nav .btn { color: red; } /* Loses to !important */
But !important has its own specificity hierarchy. When two !important declarations conflict, the cascade algorithm runs within the important tier:
.btn { color: blue !important; } /* (0,1,0) important */
.nav .btn { color: red !important; } /* (0,2,0) important — wins */
Production Scenario: Component Library Override
This comes up constantly. You're using a component library that styles buttons like this:
/* library.css */
.ui-btn.ui-btn--primary {
background: #0066ff;
color: white;
padding: 0.75rem 1.5rem;
}
You want to change the background for your app. Here are your strategies, ranked best to worst:
/* Strategy 1: Cascade layers (best — clean separation) */
@layer library, app;
@layer library { @import 'library.css'; }
@layer app {
.my-btn { background: #ff6600; }
}
/* Strategy 2: Match specificity + source order */
.ui-btn.ui-btn--primary { background: #ff6600; }
/* Strategy 3: Exceed specificity minimally */
.app .ui-btn.ui-btn--primary { background: #ff6600; }
/* Strategy 4 (avoid): !important */
.my-btn { background: #ff6600 !important; }
| What developers do | What they should do |
|---|---|
| Treating specificity as a single number (IDs=100, classes=10, elements=1) The columns never overflow into each other. (0,20,0) loses to (1,0,0). | Specificity is compared column by column — 20 classes cannot beat 1 ID |
| Using :is() with high-specificity selectors without realizing the inflation :is(.foo, #bar) gives everything #bar's specificity (1,0,0), even when .foo matched | Use :where() when you want matching without specificity cost |
| Reaching for !important as the first fix for override problems !important creates a new arms race — the next override needs !important too | Understand why your selector lost and fix the specificity directly |
| Assuming inline styles can't be overridden Inline styles have high specificity but !important operates at a higher cascade level | !important in a stylesheet beats inline styles. Cascade layers can also help. |
- 1Specificity is a (ID, CLASS, ELEMENT) tuple — compared column by column, never as a single number
- 2:where() contributes zero specificity — use it for overridable defaults and resets
- 3:is() and :not() take the specificity of their most specific argument
- 4!important creates a separate cascade tier — within it, normal specificity rules still apply
- 5Cascade layers (@layer) let you control priority independent of specificity