Selectors and specificity
Selectors are how you point at elements. There are a lot of them, you will use about twelve, and the part that actually causes trouble is not the selectors — it is specificity, which decides who wins when two of them disagree.
The ones you will use
h1 { } /* type: every h1 */
.card { } /* class: every element with class="card" */
#main { } /* id: the one element with id="main" */
* { } /* everything */
[type="email"] { } /* attribute */
Use classes for nearly everything. Type selectors are for base styles (body,
a, h1); ids are for linking and JavaScript, not styling, for reasons the
specificity section makes obvious.
Combinators: the four ways to relate elements
nav a { } /* descendant: any a inside nav, at any depth */
nav > a { } /* child: only a direct child of nav */
h2 + p { } /* next sibling: the p immediately after an h2 */
h2 ~ p { } /* subsequent siblings: every p after an h2, same parent */
The descendant/child distinction matters constantly. With a list-based nav from module 1:
<nav><ul><li><a href="/">Home</a></li></ul></nav>
nav > a matches nothing — the a is a grandchild, inside li. nav a
matches it. This is one of the most common "why is my CSS not applying" causes, and
devtools will show you the rule simply is not there.
Grouping
h1, h2, h3 { line-height: 1.2; }
One rule, three selectors. A single mistake in one of them — h2,,h3 — invalidates
that selector only in modern browsers, not the whole rule. Older behaviour
discarded the lot, which is why you will see advice to keep them on separate lines.
Pseudo-classes: state
a:hover { } /* mouse over — not available on touch */
a:focus { }
a:focus-visible { } /* focused AND the browser thinks a ring is warranted */
a:active { } /* while being pressed */
a:visited { }
input:disabled { }
input:checked { }
input:required { }
input:user-invalid { } /* invalid, but only after they have interacted */
Two things here matter more than the rest.
Use :focus-visible, not :focus, for focus rings. :focus fires when you
click a button with a mouse too, so a designer sees an "ugly" outline on click and
removes it — which destroys keyboard navigation for everybody. :focus-visible
fires for keyboard focus and not for a mouse click, so there is no longer any
reason to remove the ring:
:focus-visible {
outline: 2px solid #1a4d2e;
outline-offset: 2px;
}
Never outline: none without a replacement. It is the single most damaging
line in web development. A keyboard user loses all idea of where they are on the
page.
:hover does not exist on a touchscreen. A phone has no hover. Anything only
reachable by hovering — a dropdown menu, a "reveal on hover" button — is
unreachable for most of your visitors. Design for tap; treat hover as decoration.
Link order: LVHA
a:link { }
a:visited { }
a:hover { }
a:active { }
They must be in that order. All four have the same specificity, so source order
decides — put :hover before :visited and a visited link never shows its hover
state. The mnemonic is LoVe, HAte.
Structural pseudo-classes
li:first-child { }
li:last-child { }
li:nth-child(3) { }
li:nth-child(odd) { }
li:nth-child(2n + 1) { }
p:only-child { }
li:not(:last-child) { }
p:empty { }
:not() is the one that saves the most code — li:not(:last-child) { border-bottom: 1px solid #ddd }
is a divider between items and not after the last one, in one rule.
Watch the difference between :first-child and :first-of-type:
<div>
<h2>Prices</h2>
<p>Atta ₹450</p>
</div>
p:first-child matches nothing — the h2 is the first child.
p:first-of-type matches the paragraph. This catches everyone once.
:is(), :where() and :has()
:is(h1, h2, h3) { margin-block: 0.5em 0.25em; } /* shorthand */
:where(h1, h2, h3) { margin-block: 0.5em 0.25em; } /* shorthand, zero specificity */
.card:has(img) { padding: 0; } /* a card that contains an img */
label:has(+ input:required)::after { content: " *"; }
:is() takes the specificity of its most specific argument. :where() always
counts as zero, which makes it the tool for writing defaults somebody can
override with a plain class. That is the difference, and it is the whole reason
:where() exists.
:has() is the one CSS lacked for twenty years: a parent selector. It is now
supported everywhere current, and it replaces a genuine amount of JavaScript.
Module 6 goes further.
Pseudo-elements: generated content
.external::after { content: " ↗"; }
.required::before { content: "* "; color: #b3261e; }
p::first-line { font-weight: 600; }
::selection { background: #cfe8d8; }
input::placeholder { color: #666; }
Two colons by convention, and content is required on ::before/::after — omit
it and nothing appears at all, which is the usual reason they "do not work".
The important limit: content generated this way is decoration, not content. Screen reader support for it is inconsistent, it cannot be selected or translated reliably, and it is not in the DOM. Never put information there. An icon, a quotation mark, a decorative arrow — fine. The word "Required", or a price — no, that belongs in the HTML.
Specificity
When two rules set the same property, and origin and importance did not decide it, specificity does. Count three numbers:
(ids, classes, types)
| Selector | Specificity |
|---|---|
* |
0,0,0 |
p |
0,0,1 |
p span |
0,0,2 |
.card |
0,1,0 |
p.card |
0,1,1 |
[type="text"], :hover |
0,1,0 |
.card .title |
0,2,0 |
#main |
1,0,0 |
#main p |
1,0,1 |
inline style="" |
beats all selectors |
!important |
a different question entirely |
Compare left to right, and it is not decimal. 1,0,0 beats 0,15,0 — one id
beats fifteen classes. Eleven classes do not "carry" into the id column.
#main p { color: red; } /* 1,0,1 — wins */
.a.b.c.d.e p { color: green; } /* 0,5,1 */
That is why ids are a bad styling tool. An id-based rule can only be overridden by
another id or by !important, so a single #sidebar p { color: #666 } becomes
something you fight in every component that ever appears in the sidebar.
Three things that do not count:
:where()is always 0,0,0, including its arguments.:not()and:is()take the specificity of their argument.:not(.card)is 0,1,0 — the same as.card.- The universal selector
*adds nothing.
Keeping specificity flat
The aim is that most of your rules sit at 0,1,0 — one class — so anything can override anything by being later.
/* fragile: 0,3,2, tied to a structure you will change */
.page .sidebar ul li a { color: #1a4d2e; }
/* one class, one job */
.sidebar-link { color: #1a4d2e; }
The long version also breaks the moment somebody wraps the list in a div. Long
descendant chains are how stylesheets become unmaintainable, and the fix is a class
on the thing you mean.
Check your work
What to use for nearly everything. Classes.
Why nav > a often matches nothing. The a is inside an li, so it is a
grandchild.
Why :focus-visible rather than :focus. It does not fire on a mouse click,
so nobody has a reason to remove the ring.
The most damaging line in web development. outline: none with no replacement.
Why hover is not a design tool. A touchscreen has no hover.
Why LVHA order. All four have equal specificity, so source order decides.
:first-child versus :first-of-type. The first child of any kind, versus the
first of that element.
:is() versus :where(). Takes its argument's specificity, versus always zero.
What :has() gives you. A parent selector.
Why nothing appears from ::before. content is missing.
Why generated content must be decoration. It is not in the DOM and screen reader support is inconsistent.
How specificity is compared. Left to right, not decimal — one id beats fifteen classes.
Why ids are bad for styling. Only another id or !important can override them.
What a long descendant chain costs. High specificity, and it breaks when somebody adds a wrapper.
Practice
- Build the list-based nav and try to style the link with
nav > a. Thennav a. - Write
h2 + pandh2 ~ pon a page with three paragraphs after a heading. Compare. - Style
:focusand remove the outline. Tab through the page. Then switch to:focus-visibleand compare clicking with tabbing. - Build a dropdown that only opens on
:hoverand try it on a real phone. - Put
a:hoverbeforea:visitedand visit the link. Explain what you see. - Use
p:first-childin a container whose first child is anh2. Then:first-of-type. - Add a divider between list items but not after the last, using
:not(). - Write the same defaults with
:is()and with:where(), then try to override each with a single class. - Use
:has()to style a card differently when it contains an image. - Write
::beforewith nocontentand confirm nothing renders. - Put the word "Required" in a
::beforeand listen to the field with a screen reader. - Make
#main p { color: red }and.a.b.c.d.e p { color: green }fight. Predict the winner first. - Take a rule of your own with three or more classes in a chain and flatten it to
one class. Then add a wrapper
divand confirm the flat version still works. - Calculate the specificity of
#nav .menu li a:hoverby hand, then check it in devtools.
Official documentation
- MDN — CSS selectors — Every selector and pseudo-class, with examples.
- MDN — Specificity — The counting rules, including how
:is(),:not()and:where()behave. - caniuse — :has() — Support for the parent selector, if you need to check a specific browser.
Next: the box model, and the one line of CSS that fixes it.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship