CSS Pseudo-classes (:hover, :focus-visible, :has, :is, :where, :not)

CSS pseudo-classes let you style elements based on state, structure, or relationships instead of adding extra classes everywhere. This article explains the most useful pseudo-classes in modern CSS, how they work, and when to use each one.

Quick answer: Pseudo-classes are selector keywords such as :hover, :focus-visible, :has(), :is(), :where(), and :not() that match elements in specific conditions. They are essential for interactive styling, cleaner selectors, and more flexible component design.

Difficulty: Beginner to Intermediate

You'll understand this better if you know: basic CSS selectors, how specificity works at a high level, and the difference between classes, elements, and states.

1. What Are CSS Pseudo-classes?

CSS pseudo-classes are selector additions that match elements in special situations. Unlike a class, you do not add a pseudo-class directly in HTML; the browser applies it when the element meets the condition.

Pseudo-classes are different from pseudo-elements such as ::before and ::after, which create or target parts of an element rather than a state or condition.

2. Why CSS Pseudo-classes Matter

Pseudo-classes help you write CSS that responds to user interaction and document structure without extra markup or scripts. They make styles more maintainable, more semantic, and often more accessible.

For example, you can:

These selectors are especially useful in component libraries, forms, navigation menus, cards, and responsive UI states.

3. Basic Syntax or Core Idea

Pseudo-classes are appended to a selector with a colon. Functional pseudo-classes use parentheses and can accept a selector list.

Simple pseudo-class syntax

The basic shape is a normal selector followed by a pseudo-class name.

a:hover {
  color: tomato;
}

This rule applies only while the link is hovered.

Functional pseudo-class syntax

Some pseudo-classes take arguments, usually selectors inside parentheses.

form:has(input:invalid) {
  border: 2px solid crimson;
}

In this case, the form matches if it contains an invalid input.

4. Step-by-Step Examples

Example 1: Hover styling for links

Use :hover to give feedback when a pointer moves over an interactive element.

a {
  color: navy;
  text-decoration: none;
}

a:hover {
  text-decoration: underline;
}

This is the classic use of a pseudo-class: the element’s appearance changes only during a specific state.

Example 2: Keyboard-friendly focus with focus-visible

Use :focus-visible to show focus styles when they are most useful, especially for keyboard users.

button:focus-visible {
  outline: 3px solid dodgerblue;
  outline-offset: 2px;
}

This keeps focus styles visible when users navigate with a keyboard, while avoiding unnecessary focus rings in some pointer interactions.

Example 3: Styling a card when it contains a checked input

:has() lets a parent element react to its descendants.

label:has(input:checked) {
  font-weight: 700;
}

This selector matches the label only when it contains a checked input, which is useful for custom checkboxes, cards, and disclosure patterns.

Example 4: Grouping selectors with is

:is() helps you avoid repetition when several selectors share the same declarations.

:is(h1, h2, h3) {
  font-family: system-ui;
  line-height: 1.2;
}

This selector is easier to read than repeating the same declaration block for each heading level.

Example 5: Lower-specificity grouping with where

:where() behaves like :is() but contributes zero specificity, which makes overrides easier.

:where(nav a) {
  text-decoration: none;
}

This is useful when you want a default style that remains easy to override later.

Example 6: Excluding elements with not

:not() matches everything except the selector you exclude.

button:not(.primary) {
  background: transparent;
}

This is handy when one style should apply broadly except for a small exception.

5. Practical Use Cases

6. Common Mistakes

Mistake 1: Removing focus styles instead of improving them

Many beginners remove outlines because they look distracting. That makes keyboard navigation much harder to use.

Problem: This removes the only visible focus indicator for some users, so keyboard navigation becomes confusing and less accessible.

button:focus {
  outline: none;
}

Fix: Use :focus-visible and style it clearly instead of hiding focus altogether.

button:focus-visible {
  outline: 3px solid royalblue;
  outline-offset: 2px;
}

The corrected version preserves accessible focus feedback while still letting you control its appearance.

Mistake 2: Expecting has to work everywhere without checking support

:has() is powerful, but older browsers may not support it. If you rely on it too heavily, some styles may simply not apply.

Problem: In unsupported browsers, the selector is ignored, so the intended parent styling never appears.

article:has(img) {
  padding: 1rem;
}

Fix: Provide a safe fallback style that still works without relational matching.

article {
  padding: 1rem;
}

article:has(img) {
  padding: 1.5rem;
}

The fallback ensures the layout stays usable even when the advanced selector is unavailable.

Mistake 3: Forgetting that is and where affect specificity differently

:is() keeps the specificity of its most specific argument, while :where() adds none. Treating them as interchangeable can make overrides unexpectedly hard or easy.

Problem: Using :is() in a base rule can make later overrides harder because the selector may be more specific than you intended.

:is(.card h2, .card h3) {
  margin-top: 0;
}

h2 {
  margin-top: 1rem;
}

Fix: Use :where() for low-specificity defaults when you expect easy overrides later.

:where(.card h2, .card h3) {
  margin-top: 0;
}

h2 {
  margin-top: 1rem;
}

The corrected version gives you the same selector convenience with much easier override behavior.

7. Best Practices

Practice 1: Use focus-visible for interactive controls

Keyboard focus should be obvious, but mouse users should not be overwhelmed by focus rings on every click. :focus-visible is the best default for buttons, links, and custom controls.

input:focus-visible,
button:focus-visible {
  outline: 2px solid #005fcc;
  outline-offset: 3px;
}

This keeps your interface accessible while remaining visually polished.

Practice 2: Use where for base styles you expect to override

If a selector is only setting defaults, low specificity is a feature, not a weakness.

:where(.menu a) {
  text-decoration: none;
  color: inherit;
}

This makes later component-specific rules easier to write and maintain.

Practice 3: Use has for structure-aware styling, not layout logic

:has() is excellent for visual state changes tied to child elements, but it should not replace clean HTML structure or correct form markup.

fieldset:has(input:invalid) {
  border-color: crimson;
}

This is a strong use case because the container reacts to the state of its contents without extra classes.

8. Limitations and Edge Cases

When a selector does not seem to work, first check whether the browser supports it, then confirm the selector is written with the correct nesting and specificity.

9. Practical Mini Project

Here is a small navigation card example that combines several pseudo-classes in one realistic pattern. It shows hover feedback, keyboard focus, grouped selectors, and a parent reaction using :has().

<nav class="site-nav">
  <a class="nav-card" href="#home">
    <span class="nav-title">Home</span>
    <span class="nav-text">Go to the dashboard</span>
  </a>

  <a class="nav-card" href="#settings">
    <span class="nav-title">Settings</span>
    <span class="nav-text">Manage preferences</span>
  </a>
</nav>

.site-nav {
  display: grid;
  gap: 1rem;
}

.nav-card {
  display: block;
  padding: 1rem;
  border: 1px solid #ccc;
  border-radius: 0.75rem;
  text-decoration: none;
  color: inherit;
}

.nav-card:is(:hover, :focus-visible) {
  border-color: #005fcc;
  box-shadow: 0 0 0 3px rgba(0, 95, 204, 0.2);
}

.site-nav:has(.nav-card:hover) {
  background: #f8fbff;
}

:where(.nav-title) {
  display: block;
  font-weight: 700;
}

:where(.nav-text) {
  display: block;
  margin-top: 0.25rem;
  color: #555;
}

This example demonstrates how pseudo-classes can work together to create a polished, accessible interaction pattern without extra scripting.

10. Key Points

11. Practice Exercise

Expected output: You should end up with hover feedback, accessible focus styling, parent-aware invalid-state styling, and cleaner grouped heading rules.

Hint: Combine :hover, :focus-visible, :has(), and either :is() or :where() depending on whether you want the rule to be easy to override.

button {
  padding: 0.75rem 1rem;
  border: 1px solid #999;
  background: white;
}

button:hover {
  background: #f3f7ff;
}

button:focus-visible {
  outline: 3px solid #005fcc;
  outline-offset: 2px;
}

.field:has(input:invalid) {
  border: 1px solid crimson;
  padding: 0.75rem;
}

:where(h1, h2, h3, h4) {
  font-family: system-ui;
  line-height: 1.2;
}

The solution combines interaction states, accessibility, structural matching, and low-specificity defaults in a practical way.

12. Final Summary

CSS pseudo-classes are one of the most useful parts of selector syntax because they let your styles respond to interaction, state, and relationships. Simple forms like :hover and :not() handle everyday styling, while modern options like :focus-visible, :has(), :is(), and :where() make component styling more powerful and maintainable.

To use them well, focus on accessibility, specificity, and browser support. Prefer :focus-visible for keyboard users, use :where() for override-friendly defaults, and reach for :has() when a parent truly needs to react to a child’s state.

As you continue learning CSS, practice combining these selectors in small components such as forms, cards, and navigation links. That is where pseudo-classes become easiest to understand and most valuable in real projects.