CSS Module 2 – Selectors
Pseudo-classes for state and structure
Style hover, focus, form and page states with CSS pseudo-classes, count siblings with :nth-child(An+B of S), and see when :user-invalid beats :invalid.
What you will learn
- Write :hover, :active, :focus, :focus-visible and :focus-within rules in an order that works
- Use structural pseudo-classes including :nth-child(An+B of S), :only-child and :empty
- Select form and document states such as :checked, :disabled, :user-invalid, :placeholder-shown, :target and :lang()
Before you start
What you’ll learn · Before you start
What you will learn
- Write :hover, :active, :focus, :focus-visible and :focus-within rules in an order that works
- Use structural pseudo-classes including :nth-child(An+B of S), :only-child and :empty
- Select form and document states such as :checked, :disabled, :user-invalid, :placeholder-shown, :target and :lang()
Before you start
On this page
A pseudo-class selects elements by something the markup alone does not say: that the pointer is over a button,
that a box is ticked, that a field holds an invalid email address, or that an item is the third in its list. It is
written with one colon after a selector, as in button:hover, input:checked or li:nth-child(3), and it combines
with the selectors of the previous lesson like any other part of a compound selector. Pseudo-classes fall into two
families. State pseudo-classes follow what the user does and what the document is doing, so the same element
matches and stops matching as the page is used. Structural pseudo-classes look at where an element sits among its
siblings, such as first, last, only or every other one.
This lesson goes through both families with stylesheets you can copy, a program that prints which siblings each
:nth-child() formula selects, and the recorded behaviour of three browser engines for focus and form validation.
Hover, active and focus
Five pseudo-classes follow the user’s pointer and keyboard:
:hovermatches while a pointing device is over the element, or over one of its descendants.:activematches while the element is being activated: while the mouse button is held down on it, or a finger presses it.:focusmatches the element that has keyboard focus, the one that typing or Enter goes to.:focus-visiblematches the focused element only when the browser decides that the focus should be shown.:focus-withinmatches the focused element and every ancestor of it, so a whole form group can react.
Several of these often match at the same moment: a button that is pressed is also hovered and focused. They all carry
the same weight as a class, so when two of them set the same property, the rule that comes later in the stylesheet
wins. Write the state that should win last: :hover before :active, so a pressed button looks pressed.
/* Interaction states for links and buttons. Each of these selectors carries the same weight, so when several match at
once the rule that comes last wins: put the state that should win at the end. */
a:link {
color: #1d4f9c;
}
a:visited {
color: #6a3d9a;
}
a:hover {
text-decoration-thickness: 0.15em;
}
.button {
background: #1d4f9c;
color: #fff;
}
/* Hover effects only where a pointer can hover, so that a tap on a phone does not leave a hover style behind. */
@media (hover: hover) {
.button:hover {
background: #163d79;
}
}
/* A ring for keyboard users. The browser decides when focus should be visible; this rule only decides how it looks. */
.button:focus-visible {
outline: 3px solid #0b6b3b;
outline-offset: 2px;
}
/* Pressed: after hover, so it wins while the button is held down. */
.button:active {
background: #0f2c58;
}
/* The whole field, label included, while the input inside it has focus. */
.field:focus-within {
background: #e8f0fd;
} :link and :visited split links into those the user has not followed yet and those they have. Because a page could
otherwise find out which sites someone has visited, browsers let a :visited rule change only colours, and scripts
reading the styles see the unvisited ones.
Touch screens have no pointer resting over the page, so browsers improvise: some never apply :hover, some apply it
briefly after a tap, and some leave it on until the next tap somewhere else. The hover media feature reports whether
the main pointing device can hover at all, so putting hover-only effects inside @media (hover: hover), as
states.css does, keeps a phone from showing a hover style stuck on the last thing tapped. Never put anything a user needs, such as a
menu, behind :hover alone.
When focus is shown: :focus-visible
Browsers do not draw a focus ring every time something gets focus. Selectors Level 4 leaves the exact rules to them
and describes the usual ones: show focus when the user moves with the keyboard, always show it on fields that take
typing, and leave it off when a button is clicked with a mouse. :focus-visible matches in exactly those moments, so
a rule written for it changes how the ring looks without changing when it appears. Recorded in Chromium 153,
Firefox 155 and WebKit 26.6 with a mouse and a keyboard:
| What the user does | Chromium 153 | Firefox 155 | WebKit 26.6 |
|---|---|---|---|
| Clicks a button | Focused, :focus-visible does not match |
Focused, :focus-visible does not match |
The button does not take focus |
| Presses Tab to reach the button | :focus-visible matches |
:focus-visible matches |
Tab skips the button, as in Safari’s default settings |
| Clicks into a text field | :focus-visible matches |
:focus-visible matches |
:focus-visible matches |
Not taking focus on a click is a deliberate choice in Safari, as MDN’s page on the button element notes, and Safari reaches links and buttons with Tab only after the user turns on “Press Tab to highlight each item on a webpage” in its Advanced settings.
Removing the ring is where accessibility breaks. Success criterion 2.4.7 of WCAG 2.2, at level AA, requires a visible
keyboard focus indicator for everything that can be operated with a keyboard, so outline: none on :focus without a
replacement leaves keyboard users unable to see where they are. Style :focus-visible instead, as states.css does,
with a ring that contrasts with the colours next to it by at least 3:1, the contrast that success criterion 1.4.11 asks
of the parts of a control. Older stylesheets put :focus-visible and its prefixed Firefox forerunner,
:-moz-focusring, in one list. Neither Chromium 153 nor WebKit 26.6 knows :-moz-focusring, and the whole rule is
lost there, as the previous lesson showed for vendor prefixes, while every current browser supports :focus-visible
on its own.
Form states
Form controls bring the largest group of state pseudo-classes. They read the control’s current state, so they keep up with the user without any script:
:checkedmatches a ticked checkbox, a chosen radio button and a selectedoption.:indeterminatematches a checkbox set to “mixed” by a script, and a group of radio buttons with none chosen.:disabledand:enabled. Afieldsetwith thedisabledattribute matches:disabled, and so does every control inside it.:requiredand:optional, from therequiredattribute.:read-onlyand:read-write. A field withreadonlyis read-only, and so is ordinary text, unless it iscontenteditable.:placeholder-shownmatches a field while its placeholder text is visible, which in practice means while it is empty.:in-rangeand:out-of-rangematch number, date and range inputs inside or outside theirminandmax.:validand:invalidfollow the field’s constraints at every moment;:user-validand:user-invaliddo the same, but only once the user has interacted with the field.
<form class="signup">
<div class="field">
<label for="email">Email</label>
<input id="email" type="email" required placeholder="you@example.com">
</div>
<div class="field">
<label for="age">Age</label>
<input id="age" type="number" min="13" max="120">
</div>
<label class="choice"><input type="checkbox" name="weekly" checked> <span>Send me the weekly list</span></label>
<fieldset disabled>
<legend>Home delivery (coming soon)</legend>
<label class="choice"><input type="radio" name="slot" value="morning"> <span>Morning</span></label>
<label class="choice"><input type="radio" name="slot" value="evening"> <span>Evening</span></label>
</fieldset>
<button type="submit">Join</button>
</form> /* Red from the moment the page loads: an empty required field is :invalid before anyone has typed. Avoid this. */
.signup input:invalid {
border-color: #b42318;
}
/* Better: red only once the user has changed the field and left it, or has tried to submit the form. */
.signup input:user-invalid {
border-color: #b42318;
background-color: #fdecea;
}
.signup input:user-valid {
border-color: #0b6b3b;
}
/* A number outside min and max. */
.signup input:out-of-range {
outline: 2px dashed #b42318;
}
/* The label text of a ticked box or a chosen radio button. */
.choice input:checked + span {
font-weight: 700;
}
/* A disabled fieldset matches :disabled, and so does every control inside it. */
.signup fieldset:disabled {
opacity: 0.6;
}
.signup input:disabled {
cursor: not-allowed;
}
/* Only while the placeholder is showing, that is while the field is still empty. */
.signup input:placeholder-shown {
border-style: dashed;
} The last pair is the one that changes what users see. An empty required field is invalid from the moment the page
loads, so a rule for :invalid paints a fresh form red before anyone has typed a letter. :user-invalid waits. The
HTML Standard turns on a field’s “user validity” when the user tries to submit the form, for every field in it, and a
reset turns it off again. Selectors Level 4 lets browsers turn it on sooner, once the user has changed the field and
moved on, and all three engines do. This diagram follows one email field through five steps, with the same results
in all three engines:
:invalid matches from the start, :user-invalid only after the user has acted
Text description of the diagram
The diagram follows a required email field through five steps, from top to bottom. At each step it says whether :invalid and :user-invalid match.
- The page loads and the field is empty. :invalid matches, because a required field is invalid while it is empty. :user-invalid does not match.
- The user clicks into the field and types abc, which is not an email address. :invalid still matches, and :user-invalid still does not, because the user is still in the field.
- The user leaves the field. After a changed value and a move of focus, :user-invalid matches as well.
- The form is reset and the field is empty again. Resetting clears the state, so :user-invalid stops matching while :invalid still matches.
- The user presses the submit button with the field still empty. Trying to submit counts as interaction for every field of the form, so :user-invalid matches again, without the field having been touched.
Chromium 153, Firefox 155 and WebKit 26.6 behaved the same way at every step.
Two details matter in practice. While the user is still typing in a field, :user-invalid does not match, so nobody
is told off halfway through an address. And a submit attempt marks every field at once, including the ones nobody
touched, which is exactly when the user should see what is missing. :user-valid and :user-invalid are Baseline
widely available.
Page and language states
A few pseudo-classes follow the document rather than a control:
:targetmatches the element whoseidequals the fragment of the page address, such as thesection id="faq"thatexample.com/help#faqpoints to. It is a simple way to highlight what a link jumped to.:lang()matches by language. Unlike the attribute selector[lang|="hi"], it also matches every element that inherits the language from an ancestor, because language passes down the tree.:dir(ltr)and:dir(rtl)match by text direction, including the direction thatdir="auto"works out from the text itself, which no attribute selector can see.:any-linkmatches every link, visited or not.
/* The section a link such as <a href="#faq"> points to. scroll-margin-top keeps it clear of a sticky header. */
section:target {
outline: 2px solid #8a5a00;
scroll-margin-top: 4rem;
}
/* Hindi and Marathi, both written in Devanagari. :lang() also matches every element that inherits the language from
an ancestor's lang attribute, not only the element that carries it. */
:lang(hi),
:lang(mr) {
font-family: "Noto Sans Devanagari", sans-serif;
}
/* The same as one :lang() with two languages. Chromium-based browsers do not support that newer form yet, and a
selector a browser does not support drops its rule, so they would ignore it. */
:lang(hi, mr) {
font-family: "Noto Sans Devanagari", sans-serif;
}
/* Right-to-left text, whether it comes from dir="rtl" or from dir="auto" and the text itself. */
blockquote:dir(rtl) {
border-inline-start: 4px solid #4a4f57;
} :lang() with a list of languages, as in :lang(hi, mr), is newer syntax that Firefox and Safari support but Chrome
and Edge do not, so in Chromium-based browsers that rule disappears. Two :lang() selectors in a list work
everywhere.
Structural pseudo-classes
The structural pseudo-classes count elements among their siblings; text between the elements never counts.
:first-child,:last-childand:only-childmatch the first child of its parent, the last, and a child that has no siblings.:nth-child(An+B)matches the children at the positions the formula produces, counting from 1, and:nth-last-child()counts from the end.:first-of-type,:last-of-type,:only-of-typeand:nth-of-type()count only the siblings of the same element type. In an article that starts with a heading,p:nth-child(2)andp:first-of-typeboth pick the first paragraph, but only the second keeps working if a figure is added before it.:rootmatches the root element,htmlin a web page.:emptymatches an element with no children.
Reading An+B
An+B selects position A×n + B for every whole number n from 0 upwards. odd is 2n+1 and even is 2n;
3n is every third child, -n+3 the first three (n = 0, 1 and 2 give 3, 2 and 1), and n+4 every child from the
fourth on. A plain number such as 4 selects that one position. The program below works out the positions for each
formula, then compares two ways of combining a formula with a class:
// Which siblings :nth-child() selects. An+B matches position i when i = A × n + B for some n = 0, 1, 2 …
// (Selectors Level 4); positions start at 1, and only elements count. With "of S", only the siblings that match S are
// numbered.
/** Reads odd, even, an integer, or An+B in its usual forms: 3n, 3n+1, -n+3, n+4, 2n-1. */
function parse(formula) {
const f = formula.replace(/\s+/g, "");
if (f === "odd") return { a: 2, b: 1 };
if (f === "even") return { a: 2, b: 0 };
const m = /^([+-]?\d*)n([+-]\d+)?$/.exec(f);
if (m) {
const a = m[1] === "" || m[1] === "+" ? 1 : m[1] === "-" ? -1 : Number(m[1]);
return { a, b: Number(m[2] ?? 0) };
}
if (/^[+-]?\d+$/.test(f)) return { a: 0, b: Number(f) };
throw new Error(`not an An+B formula: ${formula}`);
}
/** Whether position i (1, 2, 3 …) is A × n + B for a whole number n ≥ 0. */
function selects({ a, b }, i) {
if (a === 0) return i === b;
const n = (i - b) / a;
return Number.isInteger(n) && n >= 0;
}
// 1. The formulas on a row of ten siblings.
console.log("Positions 1 to 10 selected by :nth-child(…)");
for (const formula of ["odd", "even", "3n", "3n+1", "-n+3", "n+4", "2n-1", "4"]) {
const hits = [];
for (let i = 1; i <= 10; i++) if (selects(parse(formula), i)) hits.push(i);
console.log(` ${formula.padEnd(6)} → ${hits.join(", ")}`);
}
// 2. Ten cards; cards 2, 3, 5, 8 and 9 have the class "featured".
const cards = Array.from({ length: 10 }, (_, k) => ({ n: k + 1, featured: [2, 3, 5, 8, 9].includes(k + 1) }));
const pick = (list, formula) => list.filter((_, k) => selects(parse(formula), k + 1));
const label = (list) => list.map((c) => `card ${c.n}`).join(", ") || "none";
console.log("\nTen cards, with cards 2, 3, 5, 8 and 9 featured");
console.log(` ${":nth-child(2n of .featured)".padEnd(37)}→ ${label(pick(cards.filter((c) => c.featured), "2n"))}`);
console.log(` ${".featured:nth-child(2n)".padEnd(37)}→ ${label(pick(cards, "2n").filter((c) => c.featured))}`);
// 3. Zebra stripes on eight table rows when row 3 is hidden.
const rows = Array.from({ length: 8 }, (_, k) => ({ n: k + 1, hidden: k + 1 === 3 }));
const visible = (list) => list.filter((r) => !r.hidden).map((r) => `row ${r.n}`).join(", ");
console.log("\nEight rows, with row 3 hidden; the rows a reader sees shaded");
console.log(` ${"tr:nth-child(even)".padEnd(37)}→ ${visible(pick(rows, "even"))}`);
console.log(` ${"tr:nth-child(even of :not([hidden]))".padEnd(37)}→ ${visible(pick(rows.filter((r) => !r.hidden), "even"))}`); Output
Positions 1 to 10 selected by :nth-child(…) odd → 1, 3, 5, 7, 9 even → 2, 4, 6, 8, 10 3n → 3, 6, 9 3n+1 → 1, 4, 7, 10 -n+3 → 1, 2, 3 n+4 → 4, 5, 6, 7, 8, 9, 10 2n-1 → 1, 3, 5, 7, 9 4 → 4 Ten cards, with cards 2, 3, 5, 8 and 9 featured :nth-child(2n of .featured) → card 3, card 8 .featured:nth-child(2n) → card 2, card 8 Eight rows, with row 3 hidden; the rows a reader sees shaded tr:nth-child(even) → row 2, row 4, row 6, row 8 tr:nth-child(even of :not([hidden])) → row 2, row 5, row 7
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node nth.mjs
The two card lines are the difference people miss. .featured:nth-child(2n) takes the cards at even positions,
cards 2, 4, 6, 8 and 10, and keeps those that happen to be featured: cards 2 and 8. :nth-child(2n of .featured) first
numbers only the featured cards, then takes the second and the fourth of them: cards 3 and 8. The browsers return the
same two results. Both selectors carry the same weight, one class and one pseudo-class.
The of S form also fixes striped tables. When a filter hides rows with the hidden attribute, plain
tr:nth-child(even) still counts the hidden ones, and the program shows rows 2 and 4 shaded with only row 3, now
invisible, between them: two shaded rows in a row. Counting only :not([hidden]) rows gives stripes that alternate
again. :nth-child(An+B of S) is Baseline widely available.
/* Stripes on the rows a reader can see: hidden rows are left out of the count. */
tbody tr:nth-child(even of :not([hidden])) {
background: #f1f3f6;
}
/* The first three items of a "top picks" list. */
.top-picks > li:nth-child(-n + 3) {
font-weight: 700;
}
/* A list with only one item needs no bullet. */
.tags > li:only-child {
list-style: none;
}
/* The first paragraph of each article, even when a heading or a figure comes before it. */
article p:first-of-type {
font-size: 1.125rem;
}
/* An empty message box takes no space. Comments do not count as content, but in today's browsers a space or a line
break does, so the server must send the element with nothing inside it. */
.message:empty {
display: none;
} :empty has one trap. Selectors Level 4 redefined it to ignore white space, but no browser has made that change: in
Chromium 153, Firefox 155 and WebKit 26.6, <p></p> and <p><!-- note --></p> match :empty, while a p that
holds one space or a line break does not. A message box that a template prints with a line break inside never matches.
Media states, and what is still new
:playing, :paused, :muted and four more pseudo-classes follow the state of an audio or video element.
Safari and Firefox support them and Chrome does not yet, so they have limited availability, and they are one of the
focus areas of Interop 2026, the yearly project in which browser makers pick features to make work alike. Use them only to
enhance a page that works without them.
Try it: the sign-up form
Paste signup.html and signup.css into the playground, then work through the states:
- The empty email field is red as soon as the preview appears. Delete the first rule, the one for
:invalid, and the field starts neutral. - Type
abcinto the email field and press Tab: it turns red only when you leave it. Complete the address and leave the field again: it turns green. - Type 5 into the age field:
:out-of-rangegives it a dashed outline at once, because the minimum is 13, and the:user-invalidcolours follow when you leave the field. - Write a rule that outlines the whole
.fieldwhile the input inside it has focus, and check it with Tab. One answer:.field:focus-within { outline: 2px solid #1d4f9c; outline-offset: 4px; }.
Check yourself
Check yourself
6 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
Interview questions
What is the difference between :focus and :focus-visible?
:focus matches whichever element has keyboard focus, however it got there: by Tab, by a mouse click or from a script.
:focus-visible matches the focused element only when the browser decides the focus should be shown: when the user
moves with the keyboard, in any field that takes typing, and after focus moves by script from an element that showed
it. After a mouse click on a button, :focus matches but :focus-visible does not. Styling :focus-visible lets you
design a clear focus ring for keyboard users without showing it on every click, and browsers use it for their own
default focus styles. What you must never do is remove the outline on :focus without a visible replacement:
WCAG 2.2 success criterion 2.4.7 requires visible keyboard focus.
Key takeaways
- A pseudo-class selects by state or by position among siblings, with one colon:
a:hover,input:checked,li:nth-child(3). - State pseudo-classes carry the same weight, so the last rule wins: put
:activeafter:hover. Keep hover-only effects inside@media (hover: hover). - Style
:focus-visiblefor the focus ring and never remove the outline without a replacement (WCAG 2.2, 2.4.7). - Use
:user-invalidrather than:invalidfor error styles: it waits until the user has changed the field and left it, or tried to submit. :nth-child(An+B of S)numbers only the siblings that match S;S:nth-child(An+B)numbers all siblings and then filters.of :not([hidden])keeps stripes right when rows are hidden.:emptystill treats a space as content in every browser, and:lang()with a list of languages does not work in Chromium-based browsers yet.
References
- Selectors Level 4 (W3C CSS Working Group)
- HTML Standard: pseudo-classes (WHATWG)
- HTML Standard: form submission and user validity (WHATWG)
- Web Content Accessibility Guidelines (WCAG) 2.2 (W3C)
- Understanding Success Criterion 2.4.7: Focus Visible (W3C Web Accessibility Initiative)
- Media Queries Level 4 (hover and pointer) (W3C CSS Working Group)
- Pseudo-classes (MDN Web Docs) (Mozilla)
- :hover (MDN Web Docs) (Mozilla)
- The button element: clicking and focus (MDN Web Docs) (Mozilla)
- :visited and privacy (MDN Web Docs) (Mozilla)
- Change Advanced settings in Safari on Mac (Apple)
- :empty (MDN Web Docs) (Mozilla)
- MDN browser-compat-data: :lang() argument lists and wildcards (Mozilla)
- web-features: media element pseudo-classes (W3C WebDX Community Group)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress