Selectors and Pseudo-Classes
IntermediateBeyond classes there is a whole vocabulary — combinators, structural pseudo-classes, :has() — that removes the need for extra markup and JavaScript.
Overview
Most production CSS uses classes and little else, which is a reasonable default but leaves a lot on the table. Structural pseudo-classes style the first, last or nth item without a special class. State pseudo-classes handle hover, focus, disabled and validity without JavaScript. And :has() — the long-requested parent selector, now supported everywhere — lets a container style itself based on what it contains. Knowing the vocabulary means writing less markup and less JavaScript, both of which are wins.
Combinators and Structure
Relationships between elements, and position within a parent.
.card .title /* descendant, at any depth */
.card > .title /* direct child only */
.a + .b /* the next sibling, immediately after */
.a ~ .b /* any following sibling */
/* Structural — no extra classes needed */
li:first-child li:last-child li:only-child
li:nth-child(2) li:nth-child(odd) li:nth-child(3n)
li:nth-last-child(2)
p:first-of-type /* first p, regardless of other siblings */
:empty /* no children at all, not even text */
/* Two very useful idioms */
.stack > * + * { margin-block-start: 1rem } /* space between */
tr:nth-child(even) { background: var(--surface) } /* zebra stripes */
/* Attribute selectors */
a[href^="http"] /* starts with */
a[href$=".pdf"] /* ends with */
[data-state="open"] /* exact */
[aria-invalid="true"] /* style from ARIA state — keeps CSS and
accessibility in sync automatically */
/* :is() and :where() shorten long lists */
:is(h1, h2, h3) + p { margin-block-start: .5rem }State
Interaction and form state, handled without JavaScript.
a:hover a:active a:visited
button:focus-visible /* keyboard focus only — NOT mouse clicks */
button:disabled
input:checked
details[open]
/* :focus-visible is the modern default. :focus fires on mouse
clicks too, which is why people removed outlines and broke
keyboard access. */
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px }
/* Form validity */
input:required input:invalid input:valid
input:user-invalid /* only after the user has interacted — use this */
input:placeholder-shown
/* The floating-label trick, with no JavaScript */
input:placeholder-shown + label { transform: translateY(1.4rem) }
/* :focus-within — style a container when anything inside has focus */
.search:focus-within { outline: 2px solid var(--accent) }
/* Hover only where hovering exists — a phone has no hover, and
sticky hover states are a common mobile bug */
@media (hover: hover) {
.card:hover { transform: translateY(-2px) }
}:has() and Pseudo-Elements
The parent selector, and generated content.
/* :has() — style a parent based on its children */
.card:has(img) { grid-template-rows: auto 1fr }
.field:has(:invalid) { border-color: var(--danger) }
label:has(input:checked) { font-weight: 600 }
form:has(:invalid) button[type=submit] { opacity: .5 }
/* Combined with sibling combinators it replaces a lot of JS: */
body:has(dialog[open]) { overflow: hidden } /* lock scroll, no JS */
/* Pseudo-elements — two colons, and they need content to render */
.badge::before { content: "★"; margin-inline-end: .25rem }
.external::after { content: " ↗" }
td::before { content: attr(data-label) } /* read an attribute */
::selection { background: var(--accent); color: white }
::placeholder { color: var(--ink-faint) }
::marker { color: var(--accent) } /* list bullets */
::first-line ::first-letter
/* Generated content is NOT reliably announced by screen readers
and cannot be selected or translated — never put meaning in it. */
.icon::before { content: "✓" } /* decorative only */Key Points to Remember
- 1Combinators and nth-child style position without adding classes; > * + * is the standard spacing idiom
- 2Attribute selectors can style from ARIA state, keeping CSS and accessibility in sync
- 3:focus-visible targets keyboard focus only, which is why it replaced the outline-removal habit
- 4:has() selects a parent by its contents and removes a surprising amount of JavaScript
- 5Generated content from ::before is decorative — it is not reliably announced, selectable or translatable
Interview Questions
Sign in to ask AriaWhat is the difference between :focus and :focus-visible?
What does .card:has(img) select?
Why should meaningful text never live in a ::before content property?
Ask Aria about Selectors and Pseudo-Classes
Your personal AI tutor — ask anything about this concept
Revision Status
Personal Notes
Sign in to save personal notes for this topic.
Discussion
Sign in to join the discussion.