Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Beginner
  • 30 minutes
  • Examples run with Node.js 24.21.0
  • By MySmartCoPilot
Free: 30 minutes

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:

  • :hover matches while a pointing device is over the element, or over one of its descendants.
  • :active matches while the element is being activated: while the mouse button is held down on it, or a finger presses it.
  • :focus matches the element that has keyboard focus, the one that typing or Enter goes to.
  • :focus-visible matches the focused element only when the browser decides that the focus should be shown.
  • :focus-within matches 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.

Link and button states in an order that works CSS · states.css
/* 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.

Color Contrast Checker (WCAG 2.2) Check that a focus ring colour reaches 3:1 against the background it is drawn on.

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:

  • :checked matches a ticked checkbox, a chosen radio button and a selected option. :indeterminate matches a checkbox set to “mixed” by a script, and a group of radio buttons with none chosen.
  • :disabled and :enabled. A fieldset with the disabled attribute matches :disabled, and so does every control inside it.
  • :required and :optional, from the required attribute.
  • :read-only and :read-write. A field with readonly is read-only, and so is ordinary text, unless it is contenteditable.
  • :placeholder-shown matches a field while its placeholder text is visible, which in practice means while it is empty.
  • :in-range and :out-of-range match number, date and range inputs inside or outside their min and max.
  • :valid and :invalid follow the field’s constraints at every moment; :user-valid and :user-invalid do the same, but only once the user has interacted with the field.
signup.html HTML · signup.html
<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>
signup.css: form states CSS · signup.css
/* 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:

Five steps with a required email field: :invalid matches at every step, :user-invalid only after leaving the field or a submit.1. The page loads, the field is empty:invalid matches · :user-invalid does not2. The user clicks in and types abc:invalid matches · :user-invalid does not3. The user leaves the field:invalid matches · :user-invalid matches4. The form is reset, the field is empty again:invalid matches · :user-invalid does not5. The user presses the submit button:invalid matches · :user-invalid matchesa changed value, then focus moves ona submit attempt counts for every field

: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.

  1. 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.
  2. 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.
  3. The user leaves the field. After a changed value and a move of focus, :user-invalid matches as well.
  4. The form is reset and the field is empty again. Resetting clears the state, so :user-invalid stops matching while :invalid still matches.
  5. 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:

  • :target matches the element whose id equals the fragment of the page address, such as the section id="faq" that example.com/help#faq points 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 that dir="auto" works out from the text itself, which no attribute selector can see.
  • :any-link matches every link, visited or not.
page-states.css: target, language and direction CSS · page-states.css
/* 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-child and :only-child match 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-type and :nth-of-type() count only the siblings of the same element type. In an article that starts with a heading, p:nth-child(2) and p:first-of-type both pick the first paragraph, but only the second keeps working if a figure is added before it.
  • :root matches the root element, html in a web page.
  • :empty matches 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:

The positions :nth-child() selects JavaScript · nth.mjs
// 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.

structure.css: stripes, first items and empty boxes CSS · structure.css
/* 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:

HTML, CSS & JS Playground (Online Editor) Paste signup.html into the HTML tab and signup.css into the CSS tab, then use the form with the mouse and the keyboard.
  1. 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.
  2. Type abc into 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.
  3. Type 5 into the age field: :out-of-range gives it a dashed outline at once, because the minimum is 13, and the :user-invalid colours follow when you leave the field.
  4. Write a rule that outlines the whole .field while 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.

  1. Question 1 of 6 A required email field is empty when the page loads, and nobody has touched it yet. Which selector matches it?

    Choose one answer.

    Show the answer to question 1

    Answer: input:invalid

    An empty required field is invalid from the start, so :invalid matches at once, which is why a rule for it paints a fresh form red. :user-invalid waits until the user has changed the field and left it, or has tried to submit the form. :out-of-range only applies to inputs with min or max.

  2. Question 2 of 6 Which items of a ten-item list does this rule make bold?

    Read the code, then choose one answer.

    li:nth-child(3n + 1) {
      font-weight: 700;
    }
    Show the answer to question 2

    Answer: Items 1, 4, 7 and 10

    3n + 1 gives 1, 4, 7 and 10 for n = 0, 1, 2 and 3, and n starts at 0, so the first item is included. 3n would give items 3, 6 and 9.

  3. Question 3 of 6 Cards 2, 3, 5, 8 and 9 of ten have the class featured. Which cards does :nth-child(2n of .featured) select?

    Choose one answer.

    Show the answer to question 3

    Answer: Cards 3 and 8, the second and fourth featured cards

    With of .featured, only the featured cards are numbered (2, 3, 5, 8 and 9 become 1 to 5), and 2n takes numbers 2 and 4 of them. Cards 2 and 8 is the answer for .featured:nth-child(2n), which numbers all ten cards and then keeps the featured ones.

  4. Question 4 of 6 .button:hover and .button:active both set the background. Why should the :active rule come second?

    Choose one answer.

    Show the answer to question 4

    Answer: Both have the same weight, so the later rule wins, and a held-down button is hovered too

    State pseudo-classes weigh the same as a class. A pressed button usually matches :hover as well, so whichever rule comes later decides the background while it is held down. Placing :active last makes it look pressed.

  5. Question 5 of 6 Which of these elements match :empty in today's browsers? Choose every one that does.

    Choose every answer that is right.

    Show the answer to question 5

    Answer:

    • <p><!-- note --></p>
    • <p></p>

    Comments never count as content, and neither does nothing at all. A child element always counts, and so, in every current browser, does a single space or line break, even though Selectors Level 4 has redefined :empty to ignore white space.

  6. Question 6 of 6 Why put hover-only effects inside @media (hover: hover)?

    Choose one answer.

    Show the answer to question 6

    Answer: Touch screens cannot hover, and their browsers apply :hover briefly, never or until the next tap

    The hover media feature tells whether the main pointing device can hover. On a phone it cannot, and a hover style may stay on whatever was tapped last, so the effect is kept for devices that really hover. Anything a user needs must not depend on hover at all.

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 :active after :hover. Keep hover-only effects inside @media (hover: hover).
  • Style :focus-visible for the focus ring and never remove the outline without a replacement (WCAG 2.2, 2.4.7).
  • Use :user-invalid rather than :invalid for 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.
  • :empty still treats a space as content in every browser, and :lang() with a list of languages does not work in Chromium-based browsers yet.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.