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.
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
.card.featured { } /* has BOTH classes */
a.button { } /* an <a> that also has class="button" */
input[type=text] { } /* an input whose type is text */
.card .featured is a descendant inside .card. .card.featured is one element with both classes. One space is the difference.
Combinators: relationships between elements
.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 */
| Written | Name | Means |
|---|---|---|
A B | Descendant | Any B inside an A, at any depth. |
A > B | Child | A B that is a direct child of an A. |
A + B | Adjacent sibling | The B immediately after an A. |
A ~ B | General sibling | Every 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.
[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-visibleso 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.
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 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.
/* 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; }
.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
contentis required for::beforeand::afterto 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::afterandinput::beforenever render.
.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.