Pseudo-classes and elements

Style states and generated parts without adding a single element to the markup.

22of 26
≈ 11Min read
8Parts

One colon or two

A pseudo-class (:hover) selects an element in a particular state. A pseudo-element (::before) selects a part of an element that is not in the DOM. One colon for a state, two for a part.

Interaction states

  • :hover before :active, or the press state never shows. The mnemonic is LVHA.
  • :focus matches any focus, including a mouse click. :focus-visible matches only when the browser thinks a focus ring is warranted — keyboard, mostly. Style that one.
  • :focus-within matches a parent containing something focused, which is how you highlight a whole form row.
in the order they must be written
a:link    { }   /* not yet visited */
a:visited { }   /* visited — very few properties may change, for privacy */
a:hover   { }
a:focus-visible { }
a:active  { }   /* while being pressed */
the two you will use most
:focus-visible {
  outline: 2px solid #4c8dff;
  outline-offset: 3px;
}

.field:focus-within {
  border-color: #4c8dff;
}

Structural

counting siblings
li:first-child { }
li:last-child  { }
li:only-child  { }

li:nth-child(3)      { }   /* the third */
li:nth-child(odd)    { }
li:nth-child(3n)     { }   /* every third */
li:nth-child(3n + 1) { }   /* 1st, 4th, 7th — the start of each row of three */
li:nth-last-child(2) { }   /* second from the end */

p:first-of-type  { }       /* the first p among its siblings, whatever precedes it */
p:nth-of-type(2) { }
child versus of-type

p:first-child means "a p that is also the first child". If an h2 comes first, it matches nothing. p:first-of-type means "the first p among its siblings" — usually what you meant.

Form states

the browser already knows
input:required      { }
input:optional      { }
input:disabled      { }
input:read-only     { }
input:checked       { }
input:indeterminate { }
input:placeholder-shown { }

/* only after the reader has interacted — no red on an untouched form */
input:user-invalid  { }
input:user-valid    { }

input:in-range      { }
input:out-of-range  { }
a custom checkbox, no JavaScript
.check input { position: absolute; opacity: 0; }

.check .box {
  width: 18px;
  height: 18px;
  border: 1px solid #999;
  border-radius: 4px;
}

.check input:checked + .box {
  background: #15161a;
  border-color: #15161a;
}

.check input:focus-visible + .box {
  outline: 2px solid #4c8dff;
  outline-offset: 2px;
}

:is, :where, :not, :has

:has() is the one that changed what is possible: it is the only way to style an element because of its contents, and it replaces a large amount of JavaScript that existed only to add a class to a parent.

the four functional ones
:is(h1, h2, h3) { margin-block: 1.5em 0.5em; }   /* specificity of the strongest argument */
:where(h1, h2, h3) { margin-block: 1.5em 0.5em; } /* zero specificity — easy to override */

.card:not(.featured, .archived) { opacity: 0.85; }

.card:has(img)            { padding-top: 0; }
label:has(input:checked)  { font-weight: 600; }
form:has(:user-invalid) button { opacity: 0.5; }

Generated content

  • content is mandatory: without it the pseudo-element does not exist.
  • A pseudo-element is a child of the element, so position: absolute inside it needs the parent to be positioned.
  • Generated text is not in the DOM. It is announced inconsistently by screen readers and cannot be reliably selected or copied — never put meaning there.
  • They do not work on void elements: img::after, input::before and br::after never render.
::before and ::after
.external::after {
  content: " ↗";
  color: #999;
}

.card::before {
  content: "";              /* required, even when empty */
  position: absolute;
  inset: 0;
  background: linear-gradient(transparent, rgb(0 0 0 / .5));
}

/* content can read an attribute */
.tag::before { content: attr(data-label) ": "; }

/* and a counter */
.steps { counter-reset: step; }
.steps li::before {
  counter-increment: step;
  content: counter(step) ". ";
}

Parts of the text

Only a small set of properties is allowed on ::first-line and ::first-letter, and ::selection accepts only colour, background, text-shadow and a couple of others.

typographic details
p::first-line   { font-variant-caps: small-caps; }
p::first-letter { float: left; font-size: 3em; line-height: 0.8; padding-right: 6px; }

::selection       { background: #ffe58a; color: #14161a; }
li::marker        { color: #999; font-variant-numeric: tabular-nums; }
input::placeholder{ color: #aaa; }
::backdrop        { background: rgb(0 0 0 / .55); }

Specificity

  • A pseudo-class counts as a class: 0-1-0.
  • A pseudo-element counts as an element: 0-0-1.
  • :not(), :is() and :has() take the specificity of their most specific argument.
  • :where() is always zero, whatever is inside it.

Practice