← back to the section

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 + p is the "spacing between" idiom.
  • Pseudo-classes are states and positions: :hover, :focus-visible (accessible focus), :checked, :nth-child.
  • nth-child counts all children, nth-of-type — only elements of its own type. Count is off — there's an extra element in the tree.
  • ::before/::after create virtual elements; without content they 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.
  • 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.