A selector is a targeting sight: it decides which elements a rule will hit. There are dozens of sights in CSS, but the everyday set is small — and it doubles as what determines specificity. Let's go from the basics to the modern capabilities.
The basics and combinators
button { } /* by tag */
.card { } /* by class — the workhorse */
#app { } /* by id — better avoided in styles */
[disabled] { } /* by attribute */
[href^="https"] { } /* attribute starts with... (also: $= ends with, *= contains) */
Combinators join selectors with tree relationships:
.card p { } /* space: descendant at ANY depth */
.card > p { } /* direct child */
h2 + p { } /* the sibling immediately after h2 */
h2 ~ p { } /* all following siblings */
The "descendant vs child" difference bites on nested structures: .menu li will color the items of a nested submenu too, .menu > li — only the top level. And the adjacent combinator + is the "spacing between, but not outside" idiom: p + p { margin-top: 1em } puts a gap only between paragraphs.
A comma-separated list is several independent selectors with one body: h1, h2, h3 { }. An error in one selector of the list cancels the entire rule — more on that below, :is() has the cure.
Pseudo-classes: states and positions
A pseudo-class selects an element by state or position, not by markup.
Interactive states:
a:hover { } /* cursor over the element */
button:active { } /* at the moment of pressing */
input:focus { } /* element has focus */
button:focus-visible { } /* focus FROM THE KEYBOARD — outline for tabbing users, none for clickers */
input:disabled { }
input:checked { } /* checkbox/radio is checked */
The :focus / :focus-visible pair is an important accessibility detail: removing the outline entirely is not an option (keyboard users lose track of focus), while :focus-visible shows the indicator only to those who navigate by keyboard.
Counting children:
li:first-child { }
li:last-child { }
li:nth-child(2) { } /* second child */
li:nth-child(odd) { } /* odd ones — zebra striping in tables */
li:nth-child(3n) { } /* every third */
li:nth-child(n + 4) { } /* from the fourth onward */
The classic gotcha is the difference between nth-child and nth-of-type. p:nth-child(2) means "an element that is the second child of its parent AND happens to be a paragraph". If the first child is a heading and the second is a paragraph, the rule fires; but p:nth-child(1) will find nothing. p:nth-of-type(2) counts only paragraphs: the second paragraph among paragraphs. If nth-child didn't fire — almost always there is an "extra" element in the tree throwing off the count.
Negation: button:not(.primary) — all buttons except the primary one; li:not(:last-child) — "everyone except the last" (the same inter-element spacing trick).
Pseudo-elements: content out of nothing
A pseudo-element (double colon) is not selecting something existing but creating a virtual element:
.required::after {
content: " *";
color: red;
}
.quote::before { content: "« "; }
.intro::first-line { font-weight: 600; }
::selection { background: #ffe08a; } /* text selection */
::before/::after insert one virtual child each at the start and end of the element. The mandatory condition is the content property (even an empty content: "") — without it the pseudo-element does not exist. Empty before/after with a background and positioning are a standard decoration technique: lines, icons, overlays — without junk <div>s in the markup. Limitation: they do not work on elements without inner content (<img>, <input>).
The modern layer: :is, :where, :has
:is() collapses repetition and "forgives" errors inside the list:
/* instead of three selectors */
:is(h1, h2, h3) > a { color: inherit; }
:where() — the same, but with zero specificity: perfect for base styles and libraries that the user should be able to override easily with a single class.
:has() — the long-awaited "parent selector": it selects an element by what it has inside:
.card:has(img) { padding: 0; } /* a card with an image */
label:has(input:checked) { font-weight: 600; } /* the label of a checked checkbox */
form:has(:invalid) button[type=submit] { opacity: .5; }
Before :has(), things like these required JavaScript. Support is already there in all modern browsers — but in projects with old browsers, check caniuse.
In short
- The everyday basics: a class plus four combinators (
,>,+,~).p + pis the "spacing between" idiom. - Pseudo-classes are states and positions:
:hover,:focus-visible(accessible focus),:checked,:nth-child. nth-childcounts all children,nth-of-type— only elements of its own type. Count is off — there's an extra element in the tree.::before/::aftercreate virtual elements; withoutcontentthey don't exist; they don't work on<img>/<input>.:is()collapses lists,:where()is the same with zero specificity,:has()is the "by contents" selector.
What to read next
- Cascade and specificity — how much each of these selectors "weighs".
- The box model — the next topic in the series.
- DOM events —
closest()and delegation: the same selectors on the JavaScript side.