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.

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 keyboard or programmatic focus, 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;
}

Reach for :focus-visible rather than plain :focus when you only want the focus ring to show for keyboard users. That keeps the outline available to people navigating without a mouse while staying out of the way during ordinary clicks.

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 really just 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. Where you need to support browsers that predate it, the older approach was to combine :invalid with :not(:placeholder-shown) so the styling only lands once something has been typed.

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 to Reach for 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.

The Gotcha: :Nth-child() Counts Every Sibling, Not Every Match

: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 pseudo-class selects an existing element in a particular state, such as :hover. A pseudo-element creates a rendering box that is not in the markup, such as ::before. The two-colon syntax marks the pseudo-element.

Why does :hover not work on touch devices?

Because there is no pointer resting over an element. Touch browsers may apply hover briefly after a tap, which can leave a state stuck. Anything that must be reachable on touch belongs on focus or a real interaction, not hover alone.

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

:focus matches whenever the element has focus, including after a mouse click. :focus-visible matches only when the browser judges a visible indicator helpful, typically keyboard navigation, which is how you keep focus rings for keyboard users without showing them on click.

Can pseudo-classes be chained together?

Yes, and they combine as an AND. Something like a:hover:not(.disabled) matches only links that are hovered and lack that class. Each added pseudo-class raises the specificity, except :not() itself, which contributes only its argument.

Does nth-child accept formulas, and can they count backwards?

It takes an An+B formula, so 2n selects every second element and 3n+1 every third starting at the first. Negative coefficients count backwards from the start, and the keywords odd and even are shorthand for the two most common cases.

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)