Selectors and cascade — combinators, pseudo-classes, and specificity
Which style wins when several apply to the same element? Adding !important fixed it, but you don't know why. This is where CSS burns you out.
Wins and losses are decided by the mechanism called the cascade, with specificity at its center. Specificity looks like a memorization drill, but the rule is just comparing three columns from the left.
What you'll learn in this chapter
- The kinds of selectors, and how combinators express relationships between elements
- The difference between the child combinator and the descendant combinator
- The difference in role between pseudo-classes and pseudo-elements
- How to compute specificity, and the rule that comparison is column by column
- What decides the winner when specificities are tied
Having read Box model makes the properties in the code examples easier to follow. It's not required.
Selectors pick out elements
A selector is a piece of code that names the elements to style. It combines HTML tag names, class names, and so on to narrow the document down to specific elements.
| Kind | Syntax | What it picks |
|---|---|---|
| Type selector | p | Every p element |
| Class selector | .card | Elements with class="card" |
| ID selector | #header | The element with id="header" |
| Attribute selector | [type="radio"] | Elements with that attribute |
| Universal selector | * | Every element |
These can be combined. p.note means "a p element that has the note class" — no space between them, unlike a combinator.
Combinators express relationships between elements
A combinator is a symbol placed between two selectors to specify how they relate positionally. What people most often get wrong here is the difference between the descendant and child combinators.
| Combinator | Syntax | What it picks |
|---|---|---|
| Descendant | div p (a space) | p elements at any depth inside a div |
| Child | div > p | Only the p elements that are direct children of a div |
| Next-sibling | h2 + p | The p element that is the immediate next sibling of an h2 |
| Subsequent-sibling | h2 ~ p | Every p element that is a subsequent sibling of an h2 |
Here is the difference in HTML:
<div>
<p>A — direct child of div</p>
<section>
<p>B — inside section (a grandchild of div)</p>
</section>
</div>
| Selector | Elements picked |
|---|---|
div p | Both A and B |
div > p | A only |
The one that picks "at any depth" is the space (descendant), not >. If anything, > narrows the range to a single level directly below. The two mean nearly opposite things, so mixing them up leaks styles onto elements you didn't intend to touch.
For components where you don't want the styles to leak deep, using > to close the range off is effective.
/* Only headings that are direct children of a card. Doesn't leak into nested cards */
.card > h3 {
margin-top: 0;
}
Pseudo-classes and pseudo-elements
Pseudo-classes
MDN defines a pseudo-class as follows:
A pseudo-class is a keyword added to a selector that lets you select elements based on information that lies outside of the document tree, such as a specific state of the selected element(s).
"Information outside the document tree" is the key phrase. You can select elements by conditions that you can't tell from the HTML alone: whether the mouse is over them, whether they have focus, or which one they are among siblings. None of that is written in the markup. Pseudo-classes are written with a single colon.
| Category | Example | What it selects by |
|---|---|---|
| State | :hover / :focus / :active | User interaction |
| Form state | :checked / :disabled / :valid | The state of an input element |
| Structural / positional | :first-child / :last-child / :nth-child() | Position among siblings |
| Language | :lang() / :dir() | Content language or writing direction |
Pseudo-classes select elements that already exist. They only narrow existing elements by state or position; they don't create new ones.
Pseudo-elements
Pseudo-elements select a part of an element or create parts that don't exist in the document. They are written with two colons.
.note::before {
content: "Note: ";
font-weight: bold;
}
input::placeholder {
color: #999;
}
| Pseudo-class | Pseudo-element | |
|---|---|---|
| Syntax | One colon (:hover) | Two colons (::before) |
| What it picks | The element itself | A part of an element, or a generated part |
| Common examples | :hover / :first-child | ::before / ::after / ::placeholder |
The number of colons is mixed for historical reasons. Only four pseudo-elements can be written with a single colon — ::before / ::after / ::first-line / ::first-letter — and later additions such as ::placeholder are invalid with one colon. When writing new code, use two colons consistently for pseudo-elements.
Specificity
When multiple declarations target the same property of the same element, the weight that decides which one to use is specificity.
Specificity is expressed as a value with three columns. Following MDN's notation, write it as ID-CLASS-TYPE.
| Column | What it contains | Value added |
|---|---|---|
| ID | ID selectors (#header) | 1-0-0 |
| CLASS | Classes (.card), attribute selectors ([type="radio"]), pseudo-classes (:hover) | 0-1-0 |
| TYPE | Type selectors (p), pseudo-elements (::before) | 0-0-1 |
The weight goes ID > class > type, with ID the strongest. If you remember class and ID the other way around, you'll disagree with the actual results.
Things that add nothing
- The universal selector
*is0-0-0. It matches every element, but contributes nothing to specificity - Combinators (
>/+/~/ space) don't raise specificity. They change what's picked, but not the weight :where()is0-0-0no matter what you put inside it
So the universal selector isn't "at the bottom of the specificity staircase" — it isn't on the staircase at all.
Compare column by column. No carrying
Specificity comparison reads the columns from the left. Once an upper column is decided, no amount of the lower columns can flip it back.
#myElement {
color: green; /* 1-0-0 ← wins */
}
.a .b .c [id="myElement"] {
color: yellow; /* 0-4-0 */
}
Four class-equivalents don't beat one ID. Adding it up like decimal (100 vs. 40) gives the same result often enough, but that's a coincidence. Even 11 classes lose to one ID, so understand it as column-wise comparison without carrying.
Pseudo-classes that inherit the weight of their arguments
:is() / :not() / :has() have no weight of their own. Instead, they take the weight of the heaviest selector inside their arguments.
:is(p, #fakeId) { } /* 1-0-0 — takes the heaviest argument, #fakeId */
:where(p, #fakeId) { } /* 0-0-0 — always zero */
:where() is the tool for zeroing out specificity. Wrapping a library's default styles in :where() lets the consumer override them with a single class.
Inline styles and !important
These two sit outside the three columns.
Inline styles (style="color: red") beat every declaration in a stylesheet that doesn't carry !important. It helps to think of them as equivalent to 1-0-0-0.
!important is a separate mechanism from specificity. In MDN's phrasing, it "technically has nothing to do with specificity", but it interacts with specificity through the cascade. Within the same origin and same layer, a declaration with !important beats one without it. If both have !important, the one with higher specificity wins.
The cascade — specificity is not the only decider
Specificity is only one of the deciders. The cascade evaluates in this order:
- Relevance — does the selector actually match the element, and does the media-query condition hold?
- Origin and importance — is it browser default, user setting, or author style; does it carry
!important? - Specificity — if the origins match, compare here
- Scoping proximity — if specificity also ties, the one closer to its scoping root wins
- Order of appearance — if everything ties, the one written later wins
!important reverses the origin order. Normally author styles win over user settings; with !important, the user settings win instead. The design is to keep the site from silencing styles that users override for accessibility.
The one worth remembering in practice is the fifth.
.button { color: blue; }
.button { color: red; } /* This one wins */
If specificity ties, the later one wins. This is why the order in which CSS files load, or the order in the bundled output, changes the result. When "styles somehow don't apply", suspect order of appearance before you reach for specificity.
There is a design path that avoids specificity fights altogether. In the utility-first approach, you compose small single-class styles, so specificity stays nearly constant and the winner is decided by order alone. It's a structural approach that avoids the specificity arms race.
Common misconceptions
"Between an ID and a class, the winner is decided by the order they're written or by how fine-grained they look." — It isn't. Compare as a triple of numbers; class always loses to ID. No number of classes reaches one ID.
"The universal selector * is the weakest." — It isn't weak; it contributes nothing to specificity at all. Its value is 0-0-0 — treated as not entering the calculation, rather than sitting at the bottom of the ranking.
"div > p selects every p at any depth inside div." — That's the descendant combinator div p (with a space). div > p picks only direct children.
"Specificity can be added up like decimal." — It's a column-wise comparison; there is no carry. No number of classes beats one ID.
"!important means 'strongest specificity'." — !important is not a specificity value; it acts at the "origin and importance" stage of the cascade. Only within the same importance are specificities compared.
"Pseudo-classes and pseudo-elements only differ in the number of colons." — Their roles differ. A pseudo-class narrows existing elements by state or position; a pseudo-element selects a part of an element or generates a new part.
Exercises
Q1. Given the following CSS and HTML, what color is the text inside the span?
/* A */
#main .title { color: blue; }
/* B */
.page .section .title span { color: red; }
<div id="main" class="page">
<h1 class="title"><span>Heading</span></h1>
</div>
Answer and explanation
Answer: blue
A and B don't even pick the same element. A picks h1.title; B picks the span. Specificity is compared only when multiple declarations target the same property of the same element, so start by checking what each one matches.
B is .page .section .title span, which needs an ancestor with .section. There is no such ancestor in this HTML, so B doesn't match at all. It gets dropped at the "relevance" stage of the cascade and never reaches the specificity comparison.
A matches h1.title and colors it blue. There's no declaration that targets the span itself, but color is inherited, so the span becomes blue too. If B did match, the span would be red — not because B has higher specificity, but because a declaration that targets the span directly beats a color that arrived by inheritance.
Before computing specificity, check that the selector actually matches. Styles that don't apply often lose to a typo in the selector rather than to a specificity duel.
Q2. Compute the specificity of the three selectors below and tell us which wins.
A: #sidebar a:hover
B: .nav .item .link.active:hover
C: * body div ul li a
Answer and explanation
Answer:
| ID | CLASS | TYPE | Notation | |
|---|---|---|---|---|
| A | #sidebar = 1 | :hover = 1 | a = 1 | 1-1-1 |
| B | 0 | .nav .item .link .active :hover = 5 | 0 | 0-5-0 |
| C | 0 | 0 | body div ul li a = 5 | 0-0-5 |
A wins. The ID column is 1 vs. 0 vs. 0, and it's decided there. B's five classes and C's five types don't matter.
The leading * in C contributes nothing to specificity, so it counts as zero. It does affect what's picked, though. * body means "a body that is a descendant of something", which succeeds because body's ancestor html matches *. Written as * html, though, html is the root and has no ancestors, so the selector matches nothing. Specificity calculation and matching are separate concerns.
Q3. What is the specificity of :where(.a, .b, #c) p?
Answer and explanation
Answer: 0-0-1
:where() is always 0-0-0 regardless of what's inside it. So only the type selector p contributes, giving 0-0-1.
For comparison, the same content with :is() — :is(.a, .b, #c) p — is 1-0-1. :is() takes the heaviest argument, which is #c.
Q4. Given the following CSS, what background color is the button?
.btn { background: blue; }
.btn { background: green; }
button.btn { background: red; }
<button class="btn">Submit</button>
Answer and explanation
Answer: red
Compare the specificities.
.btn(lines 1 and 2) is0-1-0button.btn(line 3) is0-1-1
Line 3 wins in the TYPE column by the button, so red wins regardless of order.
If line 3 didn't exist, lines 1 and 2 would tie at 0-1-0, and the later one — green — would win.
Summary
- Selectors pick elements using combinations of type, class, ID, attribute, and universal
- The descendant combinator (a space) picks at every depth; the child combinator
>picks only direct children - A pseudo-class uses one colon and narrows existing elements by information outside the document tree (state, position)
- A pseudo-element uses two colons and picks a part of an element or generates a new part
- Specificity is a three-column value
ID-CLASS-TYPE, compared from the left column first. No carrying - The universal selector and combinators contribute nothing to specificity.
:where()is always zero - When specificities tie, the later one wins. When styles don't apply, suspect the order first
- Tailwind CSS Guide — State variants — Handling pseudo-classes with utility classes
- Tailwind CSS Guide — The utility-first mindset — Avoiding specificity conflicts by design
- MDN — Specificity
- MDN — Cascade
- MDN — Pseudo-classes
What to read next
- Declarative and imperative UI — On to Part 2. Moving to writing UI as a function of state