Selectors & the Cascade
How CSS decides which rule wins.
CSS is two things: a set of selectors (how do I pick the elements I want?) and a set of declarationsapplied to them. Selectors are where most CSS confusion lives. The moment you understand how the browser decides "which rule wins," CSS stops feeling magical.
The basic selectors
- Type: matches an HTML element.
pselects every paragraph. - Class: matches elements with a class.
.cardselects every element withclass="card". - ID: matches one element by id.
#headerselectsid="header". IDs should be unique per page. - Attribute: matches based on attributes.
[type="email"]selects email inputs.[href^="https"]matches links starting with https. - Universal:
*matches everything.
/* Type */
p { line-height: 1.6; }
/* Class */
.button { padding: 0.5rem 1rem; }
/* ID */
#site-header { background: #1d4ed8; }
/* Attribute */
input[type="email"] { font-family: monospace; }
a[href^="https"] { color: green; }
a[href$=".pdf"]::after { content: " (PDF)"; }Combinators: relationships between selectors
- Descendant: space.
article p= anypinside anarticle, at any depth. - Child:
>.ul > li= direct children only. - Adjacent sibling:
+.h2 + p= thepimmediately after anh2. - General sibling:
~.h2 ~ p= everypthat comes after anh2at the same level.
Pseudo-classes (state and structure)
Pseudo-classes start with one colon and describe a state or position of an element.
:hover- the cursor is over the element.:focus- the element has keyboard or programmatic focus.:focus-visible- focus, but only when shown by keyboard (not mouse). This is what you want for outlines.:active- the element is being clicked right now.:disabled,:checked,:required,:valid,:invalid- form states.:first-child,:last-child,:nth-child(2n+1)- structural.
:focus shows the outline on mouse click too, which looks ugly. :focus-visible only triggers when the browser thinks the user is keyboard-navigating. Use it for visible focus indicators.Pseudo-elements (generated content)
Pseudo-elements use two colons. They let you target parts of an element that aren't real elements in the HTML.
::before/::after- insert content before or after an element. Requirescontent.::placeholder- style placeholder text inside inputs.::selection- style highlighted/selected text.::marker- style list bullets/numbers.
/* Add a leading icon */
.external-link::after {
content: " ↗";
}
/* Style the placeholder */
input::placeholder { color: #94a3b8; }
/* Custom selection color */
::selection { background: #1d4ed8; color: white; }Specificity: the four-number tuple
When two rules target the same element, the browser computes a specificity score for each and the higher one wins. Specificity is a four-number tuple, read left to right:
- Inline styles (
style="..."): 1, 0, 0, 0 - IDs: 0, 1, 0, 0 per ID
- Classes, attributes, pseudo-classes: 0, 0, 1, 0 each
- Type selectors and pseudo-elements: 0, 0, 0, 1 each
/* (0,0,0,1) */
p { color: black; }
/* (0,0,1,0) */
.lead { color: blue; }
/* (0,0,1,1) */
p.lead { color: red; }
/* (0,1,0,0) */
#highlight { color: green; }The browser compares tuples component-by-component. (0,1,0,0) beats (0,0,9,0)because the ID column wins before classes even count. This is why "just add one more class" doesn't always work to override an ID.
Source order: the tiebreaker
If specificity is equal, the rule that appears later in the stylesheet wins. This is why CSS imports order matters: a theme imported after a reset overrides the reset.
!important: the last resort
Adding !important to a declaration trumps specificity. It is also the fastest way to make a stylesheet impossible to maintain. Limit it to:
- Utility classes that should always win (some frameworks).
- Fixing bugs in third-party CSS you can't edit.
- Accessibility overrides that must win.
The modern helpers: :is(), :where(), :has()
These three pseudo-classes changed CSS in the last few years.
:is(...)- "match any of these." Specificity is the most-specific argument.:is(h1, h2, h3) atargets links in any heading.:where(...)- same as:isbut with zero specificity. Perfect for resets and themeable defaults that should be easy to override.:has(...)- the parent selector. Selects an element that contains something matching the inner selector.article:has(img)= articles that have images.
/* Match links in any heading */
:is(h1, h2, h3) a { color: inherit; }
/* Style a card differently if it has an image */
.card:has(img) { padding: 0; }
.card:has(img) h3 { margin-top: 1rem; }
/* Reset styles that don't fight you later */
:where(button, input, textarea) { font: inherit; }:has()you can finally style a parent based on its children. "A form with an invalid field," "A list with more than 3 items," "A card containing an image" - all one selector now.Try it: selector experiments
Quick quiz
Which selector is more specific: '#main .card' or '.page .container .card.featured'?
Recap
- Selectors: type, class, ID, attribute, universal.
- Combinators:
space(descendant),>(child),+(adjacent),~(sibling). - Pseudo-classes (one colon) describe state.
:focus-visibleis the modern focus default. - Pseudo-elements (two colons) target generated content.
::before/::afterneedcontent. - Specificity is a 4-number tuple: inline, IDs, classes/attr/pseudo, types. Tiebreaker: source order.
!importantonly when truly nothing else works.:is(),:where(),:has()- modern selectors that make CSS expressive again.