Skip to content

Selectors and Specificity

beginner12 min read

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.

Mental Model

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).

SelectorIDsClassesElementsSpecificity
p001(0,0,1)
.card010(0,1,0)
#hero100(1,0,0)
p.card011(0,1,1)
#hero .card p111(1,1,1)
#hero #sidebar .card210(2,1,0)
div.card[data-active]:hover031(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 */
Common Trap

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 */
Execution Trace
Match
Find all selectors that match this element
.btn, #nav .btn, .card .btn
Sort by importance
Separate !important from normal
!important declarations checked first
Sort by specificity
Within each tier, compare tuples
(1,0,0) beats (0,99,0)
Sort by order
Equal specificity? Last declaration wins
Only matters when everything else ties
Result
Winning declaration applied
Computed value locked in

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 doWhat 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.
Quiz
What is the specificity of the selector: div#main .card:hover::after?
Quiz
What is the specificity of :where(#hero, .card) p?
Quiz
Two rules both use !important. .sidebar a { color: blue !important; } and nav a { color: red !important; }. Which color wins?
Key Rules
  1. 1Specificity is a (ID, CLASS, ELEMENT) tuple — compared column by column, never as a single number
  2. 2:where() contributes zero specificity — use it for overridable defaults and resets
  3. 3:is() and :not() take the specificity of their most specific argument
  4. 4!important creates a separate cascade tier — within it, normal specificity rules still apply
  5. 5Cascade layers (@layer) let you control priority independent of specificity