Selectors

Target exactly the elements you mean, using the simplest selector that does it.

02of 26
≈ 12Min read
9Parts

The basic four

Class is the workhorse. Type selectors are for defaults — body, h1, a. Ids are for addressing, not styling: they are impossible to override without another id, which turns into a fight you will lose later.

what you will write most days
p          { }   /* type: every p */
.card      { }   /* class: every element with class="card" */
#main      { }   /* id: the one element with id="main" */
*          { }   /* universal: everything */

Combining on one element

no space means the same element
.card.featured   { }   /* has BOTH classes */
a.button         { }   /* an <a> that also has class="button" */
input[type=text] { }   /* an input whose type is text */
The space changes everything

.card .featured is a descendant inside .card. .card.featured is one element with both classes. One space is the difference.

Combinators: relationships between elements

the difference in practice
.menu li      { }   /* every li anywhere inside .menu, nested lists included */
.menu > li    { }   /* only the top-level items */

h2 + p        { }   /* the paragraph right after a heading — a lede */
h2 ~ p        { }   /* every paragraph after that heading */
WrittenNameMeans
A BDescendantAny B inside an A, at any depth.
A > BChildA B that is a direct child of an A.
A + BAdjacent siblingThe B immediately after an A.
A ~ BGeneral siblingEvery B after an A, same parent.

The child combinator is the one that saves you when a component contains another copy of itself.

Attribute selectors

These are how you style what the markup means rather than what a class says. A link to a PDF can get an icon from [href$=".pdf"] without anyone remembering to add a class.

matching on attributes
[disabled]            { }   /* present at all */
[type="email"]        { }   /* exactly */
[href^="https://"]    { }   /* starts with */
[href$=".pdf"]        { }   /* ends with */
[href*="domvybe"]     { }   /* contains */
[lang|="en"]          { }   /* en, or en-GB, en-US… */
[class~="card"]       { }   /* one word in a space-separated list */
[data-state="open" i] { }   /* i = case-insensitive */

Pseudo-classes: an element in a state

  • :hover, :focus, :focus-visible, :active — interaction. Prefer :focus-visible so a ring appears for keyboard users, not on every mouse click.
  • :first-child, :last-child, :only-child — position among siblings.
  • :nth-child(2n), :nth-child(3n+1), :nth-last-child(2) — counting.
  • :first-of-type, :nth-of-type(2) — counting only elements of that type.
  • :empty — no children at all, not even whitespace.
  • :checked, :disabled, :required, :user-invalid — form state.
  • :target — the element the URL fragment points at.
zebra striping and a first row
tbody tr:nth-child(odd) { background: #fafafa; }

.list li:not(:last-child) { border-bottom: 1px solid #eee; }

:is, :where and :not

These three take a list of selectors and shorten what would be long repetition. :is() takes the specificity of its strongest argument; :where() always has zero specificity, which makes it the right tool for defaults you want easy to override.

the same rule, three ways
/* the long way */
.prose h1, .prose h2, .prose h3 { margin-block: 1.5em 0.5em; }

/* :is — one rule, specificity of .prose h3 */
.prose :is(h1, h2, h3) { margin-block: 1.5em 0.5em; }

/* :where — zero specificity, so a single class beats it later */
:where(.prose h1, .prose h2) { margin-block: 1.5em 0.5em; }

/* :not takes a list too */
.card:not(.featured, .archived) { opacity: 0.8; }

:has — styling a parent

For twenty years CSS could not look upwards. :has() can: it selects an element because of what is inside it, or what follows it.

things that were impossible
/* a card that contains an image looks different */
.card:has(img) { padding-top: 0; }

/* a label whose input is checked */
label:has(input:checked) { font-weight: 600; }

/* a form with any invalid field */
form:has(:user-invalid) .submit { opacity: 0.5; }

/* a heading followed by a figure */
h2:has(+ figure) { margin-bottom: 0.5em; }
Read it as "which has"

.card:has(img) is "a card which has an image". The element that gets styled is always the one before the colon.

Pseudo-elements: parts of an element

  • content is required for ::before and ::after to exist at all — content: "" is the usual value.
  • Generated content is not in the DOM: it cannot be selected, copied reliably, or read as structure. Never put meaningful text there.
  • They do not work on void elements: img::after and input::before never render.
two colons
.quote::before { content: "\201C"; }   /* a generated box before the content */
.quote::after  { content: ""; }

p::first-line   { }
p::first-letter { }
::selection     { background: #ffe58a; }
li::marker      { color: #999; }
input::placeholder { color: #aaa; }

Write the simplest selector that works

A long selector — .page .content .card .body p a — is slower to read, harder to override and breaks the moment the markup is rearranged. A single class on the element you mean does the same job and survives.

Depth is rarely what you want. Reach for a class, and use combinators only when the relationship is genuinely the thing you are styling.

Practice