CSS Pseudo-Classes

A pseudo-class is a keyword added to a selector, starting with a single colon (:), that targets an element based on a state or position that isn’t described by the document’s markup alone. Instead of adding a class like class="active" with JavaScript, you can let the browser tell you when a link is being hovered, when an input is focused, or when a list item is the third child of its parent. Pseudo-classes are what make CSS feel interactive and structurally aware without touching a single attribute in your HTML.

Overview / How it works

Every element the browser renders exists in a tree, and at any moment it also exists in one or more states: it might be hovered by the mouse, focused by the keyboard, checked (if it’s a checkbox), or simply be the first or last child of its parent. Pseudo-classes let a selector say “match this element, but only while this condition is true.” The browser re-evaluates these conditions continuously as the user interacts with the page or as the DOM changes, which is why :hover styles appear and disappear instantly without any script running.

Pseudo-classes fall into a few broad families you’ll use constantly:

  • Link and user-action states:link, :visited, :hover, :focus, :active — describe interaction with pointing devices, keyboards, and link history.
  • Structural (tree-position) pseudo-classes:first-child, :last-child, :nth-child(), :nth-of-type(), :only-child — describe an element’s position among its siblings, computed purely from the DOM structure.
  • Form and input state:checked, :disabled, :enabled, :required, :invalid, :placeholder-shown — reflect the live state of form controls.
  • Logical/functional pseudo-classes:not(), :is(), :where() — take other selectors as arguments and combine or negate them.

Structurally, a pseudo-class attaches directly to a simple selector with no space: a:hover means “an <a> element, while it is being hovered,” which is very different from a :hover (a space makes it a descendant combinator, meaning “any hovered element inside an <a>“). This distinction trips up beginners constantly and is covered in Common Mistakes below.

In terms of the cascade, a pseudo-class contributes to specificity exactly like a class selector does — it adds one to the “class/attribute/pseudo-class” column of the specificity calculation, regardless of how simple or complex its own condition is. This matters when you’re layering :hover on top of an ID-based rule and wondering why the hover color never shows up.

Syntax

The general shape of a pseudo-class selector is a base selector immediately followed by a colon and the pseudo-class name, with no whitespace in between. Some pseudo-classes accept an argument in parentheses.

selector:pseudo-class { property: value; }
selector:pseudo-class(argument) { property: value; }
Pseudo-class Matches
:hover Element currently under the pointer
:focus Element currently receiving keyboard/input focus
:active Element being pressed/clicked at this instant
:visited A link whose URL is in browser history
:first-child / :last-child Element that is the first/last child of its parent
:nth-child(an+b) Element whose sibling position matches the an+b formula
:nth-of-type(an+b) Element whose position among same-tag siblings matches the formula
:not(selector) Element that does NOT match the given selector
:checked A checked checkbox, radio, or selected option
:disabled / :enabled Form control that is disabled/enabled

Examples

Example 1: Link states in the correct order

This targets a plain <a href="#">link</a> element and styles each interaction state differently.

a:link {
  color: #0645ad;
}

a:visited {
  color: #551a8b;
}

a:hover,
a:focus {
  color: #d2373c;
  text-decoration: underline;
}

a:active {
  color: #cc0000;
}

Result: An unvisited link renders blue. Once the user has visited its URL, it turns purple. While the mouse is over it or it has keyboard focus, it turns red and gains an underline. At the instant it’s being clicked (mouse button down), it flashes a darker red.

The order here — link, visited, hover/focus, active — matters. Because all four rules have identical specificity, later rules in the source win ties. If :hover were written before :visited, a visited link’s purple color would override the hover color due to source order, even while hovered.

Example 2: Structural pseudo-classes for a striped table

Applied to a <table> with several <tr> rows and <td> cells.

table {
  border-collapse: collapse;
  width: 100%;
}

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

tr:first-child {
  font-weight: bold;
}

tr:last-child td {
  border-bottom: 2px solid #333;
}

td {
  padding: 8px 12px;
  border-bottom: 1px solid #ddd;
}

Result: Every other table row gets a light gray background, producing the classic “zebra striping” effect. The very first row renders in bold text (useful when it’s a header row without a real <th>). The last row’s cells get a thicker bottom border, visually closing off the table. All cells get consistent padding and a thin bottom divider.

:nth-child(even) is shorthand for the formula 2n — it selects the 2nd, 4th, 6th… row, counting from 1 among all of the parent’s children.

Example 3: Form states with :focus, :invalid, :disabled, and :not()

Applied to <input> and <button> elements inside a form.

input {
  border: 1px solid #ccc;
  border-radius: 4px;
  padding: 8px;
}

input:focus {
  outline: none;
  border-color: #2684ff;
  box-shadow: 0 0 0 3px rgba(38, 132, 255, 0.25);
}

input:invalid:not(:placeholder-shown) {
  border-color: #d93025;
}

input:disabled {
  background-color: #f5f5f5;
  color: #999;
  cursor: not-allowed;
}

button:not([disabled]):hover {
  background-color: #1a73e8;
  color: #fff;
  cursor: pointer;
}

Result: Inputs get a subtle gray border by default. When an input is focused it gets a blue border plus a soft blue glow instead of the default browser outline. An input that fails HTML validation (e.g. a required email field with bad content) only turns its border red once the user has actually typed something — :placeholder-shown is used to suppress the red state while the field is still empty and showing its placeholder. Disabled inputs are grayed out with a “not-allowed” cursor. Any button that isn’t disabled turns blue with white text on hover, while a disabled button ignores the hover rule entirely because :not([disabled]) excludes it.

How it works step by step / Under the hood

When the rendering engine builds the render tree, it doesn’t just match static selectors once — many pseudo-classes are re-evaluated continuously as the page is used:

  • State pseudo-classes (:hover, :focus, :active, :checked) are tied to the UI event loop. Every time the pointer moves, a key is pressed, or a control’s value changes, the browser recomputes which elements currently match these pseudo-classes and triggers a style recalculation (and repaint) if the matched set changed.
  • Structural pseudo-classes (:nth-child(), :first-child, etc.) are computed purely from the DOM tree at layout/style time. For :nth-child(an+b), the engine numbers each child starting at 1, then tests whether that number satisfies an+b for some non-negative integer n. 2n matches 2, 4, 6…; 2n+1 matches 1, 3, 5…; a plain integer like 3 matches only the third child.
  • Specificity: a pseudo-class counts as one selector in the same specificity tier as a class or attribute selector — written (0, 1, 0) in the common (IDs, classes, elements) notation. :not(), :is(), and similar functional pseudo-classes are special: they don’t add their own weight, but instead contribute the specificity of their most specific argument. So :not(#nav) is as specific as an ID selector, while :not(p) is as specific as an element selector.
  • Cascade resolution: when two rules have equal specificity and both apply, the one later in the stylesheet (or later in document order for equal-origin stylesheets) wins. This is why the order of :link/:visited/:hover/:active in Example 1 is not arbitrary — it’s a memorized convention (“LVHA”) that ensures hover and active can override visited.

Common Mistakes

Mistake 1: Adding a space before the pseudo-class

A stray space turns a pseudo-class attached to an element into a descendant combinator, changing the meaning entirely.

a :hover {
  color: red;
}

This is valid CSS, but it does not mean “style the link on hover.” It means “style any hovered descendant element found inside an <a>,” which almost never matches anything useful and will not turn the link itself red. The fix is to remove the space so the pseudo-class attaches directly to the selector it modifies:

a:hover {
  color: red;
}

Mistake 2: Confusing :nth-child() with :nth-of-type()

Suppose you want to highlight every other paragraph inside a section that also contains headings.

p:nth-child(2n) {
  background: yellow;
}

This is also valid CSS, but it rarely does what’s intended. :nth-child() counts an element’s position among all of its parent’s children, regardless of tag name, and then checks whether that specific child happens to be a <p>. If the section is <h2>, <p>, <p>, <h2>, <p>, the second child overall is the first <p>, so the highlighting lands on unexpected paragraphs and skips others. What’s usually wanted is :nth-of-type(), which counts only among siblings of the same tag:

p:nth-of-type(2n) {
  background: yellow;
}

Best Practices

  • Always write link pseudo-classes in the order :link, :visited, :hover/:focus, :active (“LVHA”) so interactive states can override the visited state.
  • Pair :hover with :focus for any interactive element (a:hover, a:focus { ... }) so keyboard users get the same visual feedback as mouse users.
  • Never remove a focus indicator with outline: none unless you replace it with an equally visible custom style via :focus or :focus-visible — invisible focus states break keyboard accessibility.
  • Prefer :nth-of-type() over :nth-child() when you specifically care about position among same-tag siblings, and reach for :nth-child() when you care about position in the whole child list.
  • Use :not() to exclude edge cases (like the last item in a list) instead of adding and removing helper classes with JavaScript.
  • Remember pseudo-classes add to specificity like a class does — if a hover style isn’t showing up, check whether a more specific rule elsewhere (like an ID selector) is winning the cascade.

Practice Exercises

Exercise 1: Given a navigation bar of <a> links, write rules so unvisited links are dark gray, visited links are the same dark gray (no purple), and hovered or focused links turn a brand blue with an underline. Think carefully about rule order.

Exercise 2: You have an <ul> of ten <li> items. Write a single selector that gives every third item (3rd, 6th, 9th…) a light blue background, and a separate rule that gives only the very last item a bold top border.

Exercise 3: Style a <input type="checkbox"> so that its sibling <label> text turns green and gets a strikethrough only when the checkbox is checked. Hint: you’ll need the :checked pseudo-class combined with a sibling combinator on the label.

Summary

  • A pseudo-class (single colon) selects elements based on state or tree position, not markup structure.
  • Interaction pseudo-classes like :hover, :focus, and :active are recomputed live as the user interacts with the page.
  • Structural pseudo-classes like :nth-child() and :first-child are computed from the DOM tree; :nth-child() counts all siblings while :nth-of-type() counts only same-tag siblings.
  • A pseudo-class adds specificity equivalent to a class selector; :not() and :is() instead borrow the specificity of their argument.
  • Never put a space between a selector and the pseudo-class that modifies it — that space turns it into a descendant combinator.
  • Always keep link pseudo-classes in LVHA order, and always give :focus the same care as :hover for accessibility.