Skip to content
academia.sh

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 an an+b formula; n starts at zero, and the valid positions the formula produces match. odd and even are 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; ::before and ::after generate 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.

Start typing to search.

↑↓ Esc navigate · open · close