Styling a Condition Rather Than an Element
A pseudo-class is written with a single colon and describes a state or a position rather than a kind of element. a:hover does not select a new element; it selects the same links, but only while the pointer is over them. li:first-child selects list items, but only the one that happens to be first.
This is what lets CSS respond to what people do without a line of JavaScript. Hover effects, focus rings, open and closed states on a details element, styling a checked checkbox, striping alternate table rows — all of it is pseudo-classes, and all of it stays in the stylesheet where it belongs.
They also count towards specificity as a class does, which matters when your states start fighting each other. a:hover scores 0-1-1, so a plain .link rule at 0-1-0 will lose to it, and a .link.link--muted rule at 0-2-0 will win — which is usually not what you meant when the mouse is over the element. Keeping state rules on the same selector as their base rule avoids the whole problem.
/* A state */
.btn:hover { background: #7a0060; }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
/* A position among siblings */
.menu li:first-child { font-weight: 600; }
.menu li:last-child { border-bottom: none; }
/* Pseudo-classes can be chained and combined */
.card:not(.is-archived):hover { border-color: #d1039e; }
/* One colon for pseudo-classes, two for pseudo-elements */
a:hover { } /* a state */
p::first-line { } /* a part of the element — the next lesson */ - Pseudo-classes work on the elements that are actually in the document, so they update the instant something changes — a checkbox is ticked, an input becomes invalid, an item is added to a list. There is nothing to re-run and no event listener to attach.
Interaction States, and the Order Links Must Be Written In
Links have four states and they must be written in a specific order: :link, :visited, :hover, :active. The reason is the cascade — all four have identical specificity, so the last one written wins any tie. Put :hover before :visited and a visited link will refuse to change colour on hover, because :visited is also true and comes later. The mnemonic people use is LoVe HAte. Note also that :visited is deliberately restricted for privacy reasons: browsers only let it change a small set of colour-related properties, so a rule that tries to change its size or content silently does nothing.
:focus matches an element that currently has keyboard focus. :focus-visible is the one you should usually style: it applies only when the browser judges that a focus indicator is genuinely useful — typically when the user arrived by keyboard rather than by clicking. That distinction is what resolves the old argument between designers who dislike an outline appearing on click and the accessibility requirement that keyboard users can see where they are. Style :focus-visible and both are satisfied.
:focus-within matches an element that contains something focused. It is quietly one of the most useful selectors in forms: highlight the whole field group when the input inside it is focused, or keep a dropdown open while any control within it has focus.
:active is the moment of pressing — mouse down, or finger down. A small, fast change here makes buttons feel physical, and it is the cheapest way to make an interface feel responsive on a phone, where there is no hover to give feedback.
/* LoVe HAte — this order matters */
a:link { color: #0a58ca; }
a:visited { color: #6f42c1; }
a:hover { color: #d1039e; }
a:active { color: #7a0060; }
/* Focus: style the keyboard case */
.btn:focus-visible {
outline: 3px solid #d1039e;
outline-offset: 3px;
}
/* Highlight the whole field group while the input inside is focused */
.field { border: 1px solid #e3e3ea; border-radius: 8px; padding: 12px; }
.field:focus-within { border-color: #d1039e; background: #fdf2fa; }
/* The press */
.btn:active { transform: translateY(1px); } :link,:visited,:hover,:active— write them in that order:visitedcan only change colour-related properties, by design:focus-visibleshows the ring for keyboard users without showing it on every click:focus-withinstyles an ancestor of the focused element- There is no hover on a touchscreen — never hide anything essential behind
:hoveralone :activegives immediate feedback on tap, where hover does not exist
Structural Pseudo-classes and the first-child Trap
Structural pseudo-classes select by position among siblings. :first-child, :last-child and :only-child are self-explanatory. :nth-child() is the flexible one: it takes odd, even, a plain number, or a formula in the form an+b. 2n matches every second element, 3n+1 matches the first of every group of three — useful for clearing the left column of a grid — and -n+3 matches the first three.
Now the trap, and it is a good one. p:first-child does not mean "the first paragraph". It means "a paragraph that is the first child of its parent". If your card starts with an <h2> followed by paragraphs, the first paragraph is the second child, so p:first-child matches nothing at all and the rule appears broken.
What you wanted is p:first-of-type, which means "the first paragraph among its siblings" regardless of what came before it. The same pairing exists throughout: :last-of-type, :nth-of-type(), :only-of-type. Whenever a :first-child rule mysteriously fails, check what the actual first child is — it is nearly always a heading.
Two more are worth knowing. :nth-last-child() counts from the end, which is how you style the last three items. And :empty matches elements with no children and no text — useful for hiding a container the server left blank — but be aware that a single space or a line break inside the element counts as content, so it will not match.
/* Zebra striping and grouping */
tr:nth-child(even) { background: #f8f8fb; }
.grid > :nth-child(3n+1) { border-left: 3px solid #d1039e; }
.list > :nth-child(-n+3) { font-weight: 600; } /* the first three */
/* The trap */
<div class="card">
<h2>Title</h2>
<p>First paragraph</p>
</div>
.card p:first-child { color: red; } /* matches nothing — h2 is first */
.card p:first-of-type { color: red; } /* this is what you meant */
/* Counting from the end */
.list > :nth-last-child(1) { border-bottom: none; }
/* Spacing between items only — no rule to undo afterwards */
.list > * + * { margin-top: 12px; } :nth-child()counts all siblings, not only the ones matching the rest of the selector.p:nth-child(2)means "the second child, and it must be a paragraph" — if the second child is a heading, nothing matches. This is the same misunderstanding as:first-child, one step along.
Form States, and :has() — the Selector That Looks Upwards
Forms come with a rich set of states. :checked matches a ticked checkbox or a selected radio button, and combined with the sibling combinator it is how custom-styled checkboxes and toggle switches are built without JavaScript. :disabled, :required and :placeholder-shown all do what their names suggest, and let a form communicate its rules visually.
:invalid needs care. It becomes true the moment the page loads, so a form with three empty required fields shows three errors before the visitor has typed anything — which feels like being told off for nothing. :user-invalid is the fix: it only becomes true after the person has actually interacted with the field or tried to submit. Where you need broader support, the older trick is to combine :invalid with :not(:placeholder-shown), so the error only shows once something has been typed.
The most significant recent addition is :has(), which finally lets a selector look at what an element contains and style the element itself. Before it, CSS could never say "style this card if it has an image" — you had to add a class in the HTML or in JavaScript. Now you can. .card:has(img) selects cards containing an image. label:has(input:required) lets you mark required fields from the label. form:has(:user-invalid) styles the whole form when any field is wrong.
It is a genuinely powerful selector, and the usual caution applies: it can make rules hard to trace, because a change deep inside a component now changes something far above it. Use it where it removes a class you would otherwise have to maintain by hand, not everywhere it happens to work.
/* A custom checkbox, no JavaScript */
.toggle input { position: absolute; opacity: 0; } /* keep it focusable */
.toggle .box { width: 20px; height: 20px; border: 2px solid #b9b9c6; border-radius: 4px; }
.toggle input:checked + .box { background: #d1039e; border-color: #d1039e; }
.toggle input:focus-visible + .box { outline: 3px solid #d1039e; outline-offset: 2px; }
/* Errors only after the person has actually engaged */
input:user-invalid { border-color: #c0392b; }
/* The older, broadly supported version of the same idea */
input:invalid:not(:placeholder-shown) { border-color: #c0392b; }
/* :has() — style a parent by its contents */
.card:has(img) { padding-top: 0; }
.card:has(.badge--sale) { border-color: #d1039e; }
label:has(input:required)::after { content: " *"; color: #c0392b; }
form:has(:user-invalid) .submit { opacity: 0.6; }
/* Combine with :not() for the negative case */
.gallery:not(:has(img)) { display: none; } :checked— a ticked checkbox or selected radio; pair it with+or~:disabled/:enabled— form control availability:required— mark required fields from CSS instead of by hand:user-invalid— invalid, but only after the user has interacted:has()— selects an element based on what is inside it- Never style a custom control by hiding the real input with
display: none— that removes it from the keyboard and from screen readers
