CSS Pseudo-classes: Hover, Focus, nth-child, and States

Published Updated

A pseudo-class is a keyword starting with a colon that matches an element in a particular state or position, without you having to add any class to the markup yourself. For the broader styling basics, start with the CSS fundamentals. This guide is part of our CSS Selectors series.

Syntax

selector:pseudo-class {
  /* declarations */
}

a:hover { }
input:focus { }
li:nth-child(2) { }

State Pseudo-classes

The state pseudo-classes match an element while the user is interacting with it, which is how you give a page life. The big ones are :hover for the pointer being over an element, :focus for an element that has focus from a pointer, keyboard, or script, and :active for the moment of a click or tap:

.button:hover {
  background: var(--color-accent-dark);
}

.button:focus-visible {
  outline: 3px solid var(--color-accent);
  outline-offset: 2px;
}

Use :focus-visible when the browser should decide whether to show a focus ring. It usually matches keyboard focus, but can also match a clicked text field. Keep a visible indicator wherever someone needs to see which control has focus.

These states matter because a browser tracks them automatically. You never toggle a class in JavaScript to say an element is hovered, the engine already knows the pointer is over it and applies the rule the instant that happens. That automatic tracking is also why :active fires so briefly. It only matches during the moment a mouse button or touch is actually down, then reverts the second the press ends.

Structural Pseudo-classes

The structural pseudo-classes match an element by its position among its siblings, so you can style rows, columns, and lists without tagging each item. The workhorse is :nth-child(), which takes a number, a keyword, or a small formula:

tr:nth-child(even) {
  background: #f6f8fa;
}

li:first-child {
  margin-top: 0;
}

li:nth-child(3n) {
  font-weight: 600;
}

The even keyword striped every other table row, :first-child cleared the top margin off the opening list item, and 3n matched every third item. That last formula counts in steps, so 3n hits the 3rd, 6th, 9th, and so on as the list grows.

These selectors save you from adding a data-index attribute or a JavaScript loop just to mark every third row. The browser already knows where an element sits among its siblings, so a formula like 3n or a keyword like even reads that position directly. :first-child and :last-child are shorthand for the edge cases of that same counting, matching only when an element is first or last among its siblings rather than at some arbitrary index.

Form-state Pseudo-classes

Forms carry a third family, and it is the one that saves the most JavaScript. The browser already tracks whether a control is checked, disabled, required, or currently failing its own validation rules, and each of those states has a selector:

input:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

input:required {
  border-left: 3px solid #d97706;
}

input:checked + label {
  font-weight: 600;
}

The third rule is worth pausing on. :checked matches the input, and the sibling combinator moves the styling onto the label next to it, which is how a custom checkbox or radio is usually built without a line of script.

Validation states come with a trap. :invalid matches as soon as the page loads, so an empty required field is styled as an error before the reader has typed anything, and a form full of red boxes on arrival is the usual result of reaching for it first:

/* fires immediately on an empty required field */
input:invalid { border-color: #dc2626; }

/* waits until the reader has actually interacted */
input:user-invalid { border-color: #dc2626; }

:user-invalid exists for exactly that, holding back until the field has been edited or the form submitted. For text fields that display a placeholder, combining :invalid with :not(:placeholder-shown) can hide the error while the placeholder is visible. This workaround requires a placeholder and does not track interaction; controls without a displayed placeholder can match immediately.

Selecting a Parent by Its Contents

For most of CSS history a selector could only look downward or sideways, never up. :has() removes that limit by matching an element based on what it contains.

/* a card that contains an image gets different padding */
.card:has(img) {
  padding-top: 0;
}

/* a form row whose input is failing validation */
.field:has(input:user-invalid) {
  border-left: 3px solid #dc2626;
}

The second example is the one that changes how forms get built. Styling the wrapper around an invalid field used to need a script watching the input and toggling a class on its parent. Now the relationship lives in the stylesheet and the browser keeps it in sync.

Two things before relying on it. Its specificity comes from its most specific argument rather than being free, in the same way as :is() and :not(), which the specificity guide works through. And it arrived considerably later than the rest of this page, so confirm it against your own support target rather than assuming it.

When Should You Use CSS Pseudo-classes?

Pseudo-classes are the right call whenever a style depends on something the browser already tracks, like hover, focus, or position among siblings. Reach for a real class instead the moment the condition is something only your application logic knows, like whether an item is the user's favorite or whether a form field failed validation server-side. Mixing the two approaches within a single stylesheet is completely normal. A shopping cart might use :hover for a delete button and a .is-selected class for an item the user picked, side by side in the same stylesheet.

Why Does :nth-child() Match the Wrong Element?

:nth-child() looks at an element's position among all of its siblings before it checks whether your selector matches. So p:nth-child(2) means "an element that is the second child and also happens to be a paragraph," which fails the moment a heading sits in slot two. This trips people up constantly because :nth-child() reads exactly like it should mean "the nth paragraph," and the name gives no hint that siblings of other tags are silently included in the count. When you really want the second paragraph regardless of what else is mixed in, use :nth-of-type(2) instead, since that counts only the elements of the same type:

/* second child overall, only if it is a <p> */
p:nth-child(2) { }

/* the second <p>, whatever sits between them */
p:nth-of-type(2) { }

Example

.nav a:hover,
.nav a:focus-visible {
  text-decoration: underline;
}

.price-list li:nth-child(odd) {
  background: #fafafa;
}
<ul class="price-list">
  <li>Starter</li>
  <li>Pro</li>
  <li>Team</li>
</ul>

The nav links underline on both hover and keyboard focus, so pointer users and keyboard users get the same cue. In the price list, the 1st and 3rd items pick up the light background from :nth-child(odd), giving you zebra striping without a single extra class. The browser recalculates those positions automatically if you add or remove an item later.

Frequently Asked Questions

What is the difference between a pseudo-class and a pseudo-element?

A CSS pseudo-class selects an element by a condition, such as :hover or :first-child. A pseudo-element styles a particular part, such as ::first-line, or generates a box, such as ::before. Pseudo-elements normally use two colons.

Why does :hover not work on touch devices?

CSS :hover is unreliable on touch-only devices because there is no pointer resting over the element. Some browsers apply hover after a tap. Keep essential controls available through taps, keyboard focus, and ordinary visible content.

What is the difference between :focus and :focus-visible?

CSS :focus matches any focused element. :focus-visible matches a focused element when the browser decides a visible indicator is needed, often during keyboard navigation. It can also match a clicked text field, so it does not mean keyboard-only focus.

Can CSS pseudo-classes be chained together?

CSS pseudo-classes can be chained so their conditions must all match. For example, a:hover:not(.disabled) requires a hovered link without that class. Most pseudo-classes add class-level specificity; :where() adds none, while :is(), :not(), and :has() use their most specific argument.

Can CSS :nth-child() use formulas or count from the end?

CSS :nth-child() accepts An+B formulas, such as 2n for even children and -n+3 for the first three children. Use :nth-last-child() to count from the end. The keywords odd and even cover the common alternating patterns.

Sources

  1. [1]
    :nth-child()
    (developer.mozilla.org)
  2. [2]
    :nth-of-type()
    (developer.mozilla.org)
  3. [3]
    :focus-visible
    (developer.mozilla.org)
  4. [4]
    :hover
    (developer.mozilla.org)