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
:hoverbefore:active, or the press state never shows. The mnemonic is LVHA.:focusmatches any focus, including a mouse click.:focus-visiblematches only when the browser thinks a focus ring is warranted — keyboard, mostly. Style that one.:focus-withinmatches a parent containing something focused, which is how you highlight a whole form row.
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 */
:focus-visible {
outline: 2px solid #4c8dff;
outline-offset: 3px;
}
.field:focus-within {
border-color: #4c8dff;
}
Structural
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) { }
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
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 { }
.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.
: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
contentis mandatory: without it the pseudo-element does not exist.- A pseudo-element is a child of the element, so
position: absoluteinside 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::beforeandbr::afternever render.
.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.
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.