Lesson 06 / 26
Pseudo-Classes and Pseudo-Elements
Pseudo-classes that select states not written in the document, nth-child arithmetic, logical selectors, and pseudo-elements that generate content absent from the tree.
Contents
Every selector so far has looked at something written in the document: element name, class, id, attribute, position in the tree. There is also information that is not written in the document. Whether a link is being hovered over, whether a form field has focus, which position a row sits at — none of this appears in the markup, because none of it is a property of the document.
This lesson defines two mechanisms: pseudo-classes, which select states not written in the document, and pseudo-elements, which target parts not present in the document tree at all. The two do different jobs, and the only difference in notation is the number of colons.
A Pseudo-Class Selects a State
A pseudo-class is written with a single colon and adds a condition to the selector about the element’s current state. It splits into two families.
The first family asks about interaction state: :hover matches while the pointer is
over the element, :focus while the element holds keyboard focus, :active while the
element is being activated, :disabled while a control is disabled, :checked while an
option is checked, :invalid while a field’s value does not satisfy its constraints. None
of these states is written in the document; they come from user interaction or from
validation results.
The second family asks about position in the tree: :first-child, :last-child,
:only-child, :empty, :root, and :nth-child(), the head of a numbered family.
nth-child Is an Arithmetic Expression
:nth-child() does not take a number, it takes a formula: an+b. Here n is an
integer starting at zero and increasing; every value the formula produces, as long as it
falls between 1 and the sibling count, matches a position.
The following program solves this formula and lists which positions match in a nine-row measurement table.
// nth-calc.mjs — solves the :nth-child(an+b) formula and lists which positions match function parse(expr) { const s = expr.replace(/\s+/g, "").toLowerCase(); if (s === "odd") return { a: 2, b: 1 }; if (s === "even") return { a: 2, b: 0 }; const m = s.match(/^([+-]?\d*)n([+-]\d+)?$/); if (!m) return { a: 0, b: Number(s) }; const aText = m[1]; const a = aText === "" || aText === "+" ? 1 : aText === "-" ? -1 : Number(aText); return { a, b: m[2] ? Number(m[2]) : 0 }; } // n = 0,1,2,... gives an+b; positions where 1 <= position <= total match function matched(expr, total) { const { a, b } = parse(expr); const c = []; for (let position = 1; position <= total; position++) { if (a === 0) { if (position === b) c.push(position); continue; } const n = (position - b) / a; if (Number.isInteger(n) && n >= 0) c.push(position); } return { a, b, matched: c }; } const TOTAL = 9; // nine rows in the measurement table for (const expr of ["odd", "even", "3", "2n+1", "3n", "3n+1", "-n+3", "n+4", "5n-2"]) { const { a, b, matched: m } = matched(expr, TOTAL); const formula = `${a}n${b >= 0 ? "+" : ""}${b}`; console.log(`:nth-child(${expr.padEnd(5)}) = ${formula.padEnd(7)} -> ${m.join(" ") || "(empty)"}`); }
:nth-child(odd ) = 2n+1 -> 1 3 5 7 9 :nth-child(even ) = 2n+0 -> 2 4 6 8 :nth-child(3 ) = 0n+3 -> 3 :nth-child(2n+1 ) = 2n+1 -> 1 3 5 7 9 :nth-child(3n ) = 3n+0 -> 3 6 9 :nth-child(3n+1 ) = 3n+1 -> 1 4 7 :nth-child(-n+3 ) = -1n+3 -> 1 2 3 :nth-child(n+4 ) = 1n+4 -> 4 5 6 7 8 9 :nth-child(5n-2 ) = 5n-2 -> 3 8
Three lines call for a further reading. odd and even are keywords that name the
formulas 2n+1 and 2n; they are not a separate mechanism. -n+3, with its negative
coefficient, means “the first three”: as n grows, the value shrinks, and the match ends
once it drops below 1. Its counterpart n+4 means “from the fourth to the end.” These two
are the way to select a range.
A warning: :nth-child() counts all siblings, regardless of type. If a section has a
heading followed by paragraphs, the first paragraph does not match p:nth-child(1) —
that position belongs to the heading. When counting by type is needed, :nth-of-type() is
written; it counts only siblings that carry the same element name.
Logical Selectors
Four pseudo-classes take a selector list and perform a logical operation.
:is() returns elements that match any one of the selectors in the list; it shortens
long selectors. :where() performs the same match but contributes zero to
specificity — why this distinction matters is shown with numbers in the next lesson.
:is(h1, h2, h3) + p { margin-block-start: 0.5em; }
This rule selects the same elements as writing three separate rules would.
:not() returns elements that match none of the items in the list:
.measurement-table td:not(.name) { text-align: right; }
:has(), in turn, reverses the direction: it selects an element based on its content.
The selector tr:has(td.missing) returns rows that contain a missing cell. This is
something combinators cannot do; a combinator could only select the element on the right.
Whether a selector is recognized by implementations can be tested. Earlier lessons showed that an unrecognized selector drops the entire rule; that also gives a way to test for it:
@supports selector(:has(*)) { tr:has(td.missing) { background-color: #fbf4f4; } }
The @supports block ignores its entire contents if the condition inside it is not met.
Writing a feature check is different from assuming a feature is supported: when the
condition is not met, what state the page is left in is decided by the author.
A Pseudo-Element Targets a Part
A pseudo-element is written with two colons and targets a part that is not present in the document tree.
::first-line selects a paragraph’s first line, ::first-letter its first letter. Neither
of these exists as an element in the document; the line break is a result of layout, and
the first line’s extent changes when the box’s width changes.
::marker targets a list item’s bullet or number, ::placeholder a form field’s
placeholder text, ::selection the text range the user has selected.
::before and ::after are different: they do not select a part, they generate one.
Neither exists without the content property being written.
td.missing::after { content: " —"; }
This rule appends a dash to the end of a missing-measurement cell’s content. The generated
content is inside the element, not outside it: ::before behaves like the element’s first
child, ::after like its last.
Generated content has two limits, and both are by design.
First, it is not in the document. Being selectable as text and copied, being indexed by a search engine, or being conveyed to assistive technology is not guaranteed. For this reason, no text carrying meaning is ever written here. The information “missing” should be stated in the document with a class and, where needed, visible text; a pseudo-element should only add a visual indicator.
Second, it cannot be selected. No other selector reaches inside a pseudo-element; the generated content has no substructure of its own.
A note on notation: the double-colon form was defined to separate a pseudo-element from a
pseudo-class. The single-colon notation :before is also accepted for compatibility with
older implementations; this course uses the double colon to keep the distinction clear.
The Station Page’s Status Styles
/* station.css — step 5: states and positions */ .measurement-table tbody tr:nth-child(odd) { background-color: #f5f7f8; } .measurement-table tbody tr:last-child { border-block-end: none; } .measurement-table td:not(.name) { text-align: right; } .measurement-table td.missing::after { content: " —"; color: #8a5b00; } a:hover { text-decoration-thickness: 2px; } a:focus-visible { outline: 3px solid #143a52; outline-offset: 2px; } input:invalid { border-color: #8a1c1c; } input:disabled { color: #6b767f; }
The first two rules of the second group carry a distinction. :hover is a state that only
occurs on pointer devices; a user navigating by keyboard never reaches it. This is why
every hint given with :hover should have a focus counterpart.
:focus-visible is used instead of :focus because the two return different sets:
:focus also matches when a field is clicked on with a pointer, :focus-visible matches
in cases where a focus indicator is needed. Removing the focus outline entirely (writing
outline: none) makes keyboard navigation impossible; if the outline is going to be
removed, a visible replacement should be put in its place.
Summary
- A pseudo-class is written with a single colon and selects a state not written in the document; it splits into two families — interaction state and position in the tree.
:nth-child()takes anan+bformula;nstarts at zero, and the valid positions the formula produces match.oddandevenare named forms of this formula.:nth-child()counts all siblings,:nth-of-type()only those of the same type; the difference shows up around interposed elements like a heading.:is(),:where(),:not(), and:has()take a selector list;:has()selects an element by its content and provides the reverse direction of a combinator.- A pseudo-element is written with two colons;
::beforeand::aftergenerate content not present in the document. This content is not reliably reachable as text, so it should carry only a visual indicator.
Next Step
More than one rule can now match the same element: a td by one rule, .missing by
another, .measurement-table td:not(.name) by a third. If all three write to the
text-align property, which one wins? The next lesson answers this question not with
intuition but with a countable ordering: it computes the specificity triple and applies
the cascade’s steps in order.
To keep your progress and take notes, Log in
My notes
Log in to take notes.