Module 2: Selectors & Combinators
Pseudo-classes and Pseudo-elements
State and structure selectors like :hover and :nth-child, and generated content with ::before and ::after, including what each does under the hood.
The two terms sound similar and do different things. A pseudo-class (one colon) selects an element that is in a certain state or position. A pseudo-element (two colons) selects a part of an element, or creates a part that does not exist in the HTML at all.
Pseudo-classes: state#
State changes as the user interacts, and the browser re-matches the selector automatically.
a:hover { text-decoration: underline; }
button:active { transform: translateY(1px); }
input:focus { outline: 2px solid #0b5fff; }
input:disabled { background: #eee; }
input:checked + label { font-weight: 700; }
A few that deserve more attention:
:focusfires for any focus, including mouse clicks on a button.:focus-visiblefires when the browser decides a focus ring is useful, which usually means keyboard focus. Use it for your focus styles so mouse users are not distracted.:focus-withinmatches a parent when any descendant is focused, so you can highlight a whole form row.
.field:focus-within { border-color: #0b5fff; }
button:focus-visible { outline: 3px solid #f5a524; outline-offset: 2px; }
Link order#
If you style all four link states, order them: link, visited, hover, active. People remember it as LVHA.
a:link { color: #0b5fff; }
a:visited { color: #6d28d9; }
a:hover { color: #0847c4; }
a:active { color: #052f80; }
This is not tradition for its own sake. These selectors have identical specificity, so the last one that matches wins. Put :hover before :link and your hover color never shows.
Pseudo-classes: position in the tree#
li:first-child { margin-top: 0; }
li:last-child { border-bottom: 0; }
li:nth-child(odd) { background: #f7f7f7; }
p:only-child { margin: 0; }
div:empty { display: none; }
:nth-child() takes a formula an+b, where n counts 0, 1, 2, and so on:
| Selector | Matches |
|---|---|
:nth-child(3) | the third child |
:nth-child(odd) | 1st, 3rd, 5th... |
:nth-child(3n) | 3rd, 6th, 9th... |
:nth-child(n+4) | 4th and everything after |
:nth-child(-n+3) | the first three |
An important detail: :nth-child counts all siblings, regardless of type. In a container with an h2 followed by three ps, p:nth-child(1) matches nothing, because the first child is the h2. When you want to count only paragraphs, use p:nth-of-type(1).
The logical pseudo-classes#
.card :is(h2, h3, h4) { margin-top: 0; }
button:not(.ghost) { background: #0b5fff; }
:where(ul, ol) { padding-left: 1.5rem; }
.field:has(input:invalid) { border-color: #b42318; }
:is() shortens repetition and is forgiving of unsupported selectors in its list. :not() excludes. :where() behaves like :is() but contributes zero specificity, which makes it ideal for defaults you want to be easy to override. :has() selects a parent based on what it contains. These four show up again when we discuss specificity.
Pseudo-elements: parts and generated content#
p::first-line { font-weight: 700; }
p::first-letter { font-size: 2em; float: left; }
input::placeholder { color: #888; }
::selection { background: #ffe58f; }
li::marker { color: #0b5fff; }
The most important two are ::before and ::after. They create a generated child box inside the element, before or after its other content. It exists in the render tree, so you can size, color and position it like a real element. It is not in the DOM, so JavaScript cannot find it with querySelector, and users cannot select its text.
.badge::after {
content: "NEW";
margin-left: 0.5rem;
padding: 0.1em 0.4em;
border-radius: 4px;
background: #0b5fff;
color: white;
font-size: 0.7em;
}
<span class="badge">Inbox</span>
Render tree:
span.badge
├── "Inbox" (real text node)
└── ::after (generated box, contains "NEW")
The content property is required. Without it, the pseudo-element is not created at all. For purely decorative shapes, use content: "" (empty string) and give the box size and a background:
.divider::before {
content: "";
display: block;
width: 3rem;
height: 4px;
background: currentColor;
}
The default display of ::before and ::after is inline. For width and height to work you need display: block or inline-block, or an absolutely positioned box.
One colon or two#
CSS3 introduced the double-colon syntax to tell pseudo-elements apart from pseudo-classes. Older browsers only understood the single colon for :before, :after, :first-line and :first-letter, so you will see both in the wild. Write double colons in new code.
Common mistakes#
- Missing
content. The pseudo-element simply does not appear. - Putting important text in
::before. Not all assistive technology treats generated content consistently, and users cannot copy it. Keep real information in HTML. - Hover-only interfaces. Touch screens have no hover. A dropdown that opens only on
:hoveris unusable on a phone. Pair it with:focus-withinor a click. - Removing the focus outline (
outline: none) without replacing it. Keyboard users lose track of where they are. - Mixing up
nth-childandnth-of-typewhen the container holds mixed elements. - Wrong LVHA order, so
:hovernever wins.
Practice#
- Make a table with zebra striping using
:nth-child(even)and a highlighted row on:hover. - Build a custom checkbox using
input:checked + label::beforefor the box and the checkmark. - Style a form so the whole
.fieldrow gets a border color when any input inside has focus, using:focus-within. - Create a container with an
h2and three paragraphs. Write one selector with:nth-childand one with:nth-of-typethat target the first paragraph, and explain why only one works.