🎯 CSS Pseudo-Classes

Pseudo-classes let you style elements based on their state, position, or relationship to other elements — without adding extra classes or JavaScript. They're one of the most powerful tools in CSS for building interactive, accessible UIs.

What is a pseudo-class?

A pseudo-class is a keyword added to a selector with a single colon (:) that targets an element in a specific state. For example, :hover matches an element only while the cursor is over it.

/* Syntax */
selector:pseudo-class { property: value; }

/* Examples */
a:hover        { color: blue; }
input:focus    { border-color: blue; }
li:first-child { font-weight: bold; }
button:disabled { opacity: 0.5; }

Pseudo-class vs pseudo-element

Easy rule: one colon = pseudo-class (state). Two colons = pseudo-element (creates virtual content).

Pseudo-class

:hover  :focus  :nth-child()

Selects an element in a particular state. The element already exists in the DOM.

Pseudo-element

::before  ::after  ::placeholder

Targets a virtual part of an element, or inserts content that isn't in the HTML.

📝 Both syntaxes work — mostly

Older CSS allowed a single colon for pseudo-elements too (:before, :after). Browsers still accept it, but always use :: for pseudo-elements in new code — it's the standard and makes intent clear.

1. User Interaction Pseudo-Classes

These fire based on what the user is physically doing — moving a cursor, clicking, or navigating with the keyboard.

:hover Cursor over an element

Matches any element while the pointer is positioned over it. Used for buttons, links, cards, nav items — almost everything interactive.

/* Button lift effect */
.btn:hover {
  background: #2e6db5;
  transform: translateY(-2px);
  box-shadow: 0 6px 16px rgba(74, 144, 226, 0.35);
}

/* Card highlight */
.card:hover {
  border-color: #4a90e2;
  box-shadow: 0 4px 14px rgba(74, 144, 226, 0.18);
}

/* Nav item */
nav a:hover {
  background: #4a90e2;
  color: white;
}
✦ Try it — hover each element
Hover this card
✅ Real project use

Always pair :hover with transition so state changes animate smoothly. A 150–200 ms ease is the sweet spot — fast enough to feel instant, slow enough to be noticeable.

:active Element is being clicked

Matches between the moment the mouse button is pressed and when it's released. Use it to give physical "press" feedback on buttons.

/* 3D press illusion */
.btn {
  box-shadow: 0 4px 0 #2e8a50;
  transition: transform 0.08s, box-shadow 0.08s;
}
.btn:active {
  transform: translateY(3px);
  box-shadow: 0 1px 0 #2e8a50;
}
✦ Try it — click and hold the button
📝 :active vs :hover order

When writing both, put :hover before :active. :active is more specific in time, so it should come last to win the cascade.

:focus Element has keyboard/click focus

Fires when an element receives focus — either by clicking it or tabbing to it with the keyboard. Critical for form inputs and interactive controls.

input:focus {
  outline: none;                            /* remove default ring */
  border-color: #4a90e2;
  box-shadow: 0 0 0 4px rgba(74, 144, 226, 0.2); /* custom ring */
}
✦ Try it — click the input or Tab to it
⚠️ Never remove focus without a replacement

outline: none without a replacement focus style breaks keyboard navigation for users who can't use a mouse. Always provide a visible focus indicator — a custom ring with box-shadow works great.

:focus-visible Keyboard focus only

Like :focus, but it only applies when the browser determines the user is navigating by keyboard (Tab, arrow keys). Mouse clicks on buttons don't trigger it. This is the modern best practice for focus rings.

/* Remove focus ring for mouse users */
.btn:focus {
  outline: none;
}

/* Show focus ring for keyboard users only */
.btn:focus-visible {
  outline: 3px solid #4a90e2;
  outline-offset: 3px;
}
✦ Try it — click with mouse vs Tab with keyboard
Link

Clicking with a mouse shows no ring. Press Tab to see the outline appear.

✅ :focus-visible is the modern standard

Use :focus-visible for interactive controls (buttons, links, custom inputs). Use plain :focus for actual text inputs — those should always show a ring since users need to know where they're typing.

2. Form & Input State Pseudo-Classes

CSS can read the state of form elements directly — no JavaScript needed for basic visual feedback on validation, required fields, and disabled states.

:checked Checkbox, radio, or option is selected

Matches a checkbox or radio button that is currently checked. Combine it with a sibling selector (+ or ~) to style adjacent elements — this is how pure-CSS toggles and custom checkboxes work.

/* Style the label when its checkbox is checked */
input[type="checkbox"]:checked + label {
  color: #4a90e2;
  font-weight: 600;
}

/* Custom CSS toggle switch */
input:checked + .track {
  background: #4a90e2;   /* turn track blue */
}
input:checked + .track::after {
  transform: translateX(20px);  /* slide the thumb */
}
✦ Try it — check the boxes, flip the toggle

:disabled :enabled Form control availability

:disabled matches form elements with the disabled HTML attribute. Use it to visually communicate that an action isn't available yet — muted colors and cursor: not-allowed are the standard pattern. :enabled matches the opposite: controls that can be interacted with.

input:disabled {
  background: #f0f0f0;
  color: #aaa;
  cursor: not-allowed;
  opacity: 0.6;
}

button:disabled {
  background: #ccc;
  cursor: not-allowed;
}

/* Target only enabled inputs for hover effects */
input:enabled:hover {
  border-color: #4a90e2;
}
✦ Try it — notice the disabled vs enabled states
Enabled input
Disabled input

:required :valid :invalid Validation states

:required matches inputs with the required attribute. :valid and :invalid match based on the browser's built-in constraint validation (email format, min/max, pattern, required).

/* Mark required fields with an orange left border */
input:required {
  border-left: 3px solid #e0922b;
}

/* Green when content is valid */
input:valid:not(:placeholder-shown) {
  border-color: #50c878;
  box-shadow: 0 0 0 3px rgba(80, 200, 120, 0.15);
}

/* Red when content is invalid (hide on empty fields) */
input:invalid:not(:placeholder-shown) {
  border-color: #e06c75;
  box-shadow: 0 0 0 3px rgba(224, 108, 117, 0.15);
}
✦ Try it — type in each field and watch the borders
Must be at least 2 characters
Must be a valid email address
Optional — but must be a valid URL if provided
✅ The :not(:placeholder-shown) trick

Without :not(:placeholder-shown), empty required inputs show as invalid on page load before the user touches them — terrible UX. The trick means "only style when the placeholder is hidden" (i.e. the user has typed something).

3. Structural Pseudo-Classes

Target elements based on their position in the document tree — no extra classes needed. Great for lists, tables, and grids.

:first-child :last-child

Match an element that is the first (or last) child of its parent. Use these to remove borders/margins on edge items without needing extra classes.

/* Remove top border on the first item */
li:first-child { border-top: none; }

/* No bottom margin on the last paragraph */
p:last-child { margin-bottom: 0; }

/* Round the corners of a bordered list */
li:first-child { border-radius: 8px 8px 0 0; }
li:last-child  { border-radius: 0 0 8px 8px; }
✦ First child is blue, last child is green, even rows are tinted
  • First item (first-child)
  • Second item
  • Third item
  • Fourth item
  • Fifth item (last-child)

:nth-child() Pick items by formula

Selects elements using a formula An+B where A is the cycle size and B is the offset. The keywords odd and even are the most common shorthand.

tr:nth-child(even) { background: #f5f5f5; } /* zebra table */

li:nth-child(odd)  { background: #eaf2ff; } /* alternating list */

li:nth-child(3n)   { color: green; }  /* every 3rd: 3, 6, 9... */
li:nth-child(3n+1) { color: blue;  }  /* 1, 4, 7, 10...         */

li:nth-child(-n+3) { font-weight: bold; } /* first 3 only */
✦ Blue = odd items  |  Green = every 3rd item
1
2
3
4
5
6
7
8
9
10
Formula Selects Use case
:nth-child(odd)1, 3, 5, 7…Zebra striping
:nth-child(even)2, 4, 6, 8…Zebra striping
:nth-child(3n)3, 6, 9, 12…Grid column end
:nth-child(-n+3)1, 2, 3First 3 items
:nth-child(n+4)4, 5, 6…Skip first 3

:nth-of-type() Position among same-type siblings

Like :nth-child() but counts only siblings of the same element type. Useful when a parent mixes element types (e.g. <p> and <h3>) and you only want to count one kind.

/* Style every other paragraph, ignoring h3 siblings */
p:nth-of-type(even) { background: #f0f7ff; }

/* Target the 2nd image in an article */
article img:nth-of-type(2) { float: right; }

/* Alternate card background colors */
.card:nth-of-type(odd)  { background: white; }
.card:nth-of-type(even) { background: #f9f9f9; }
📝 :nth-child vs :nth-of-type

:nth-child(2) — "the element that is the 2nd child of its parent"
:nth-of-type(2) — "the 2nd element of this tag type inside its parent"

If your markup is homogeneous (all li inside a ul), they behave identically. They differ only when siblings are of mixed types.

:not() Exclude elements

Matches elements that do not match the selector inside. A much cleaner alternative to writing a rule and then overriding it.

/* Add divider between items — but not after the last one */
li:not(:last-child) {
  border-bottom: 1px solid #eee;
}

/* Style all buttons except the cancel/ghost ones */
button:not(.btn-ghost) {
  background: #4a90e2;
  color: white;
}

/* All inputs except disabled ones */
input:not(:disabled):hover {
  border-color: #4a90e2;
}

/* Multiple exclusions (modern CSS) */
p:not(.intro):not(.note) {
  color: #555;
}
✦ Only the "active" pill is fully visible — others use :not(.is-active) to fade
  • All
  • Design
  • Code
  • UX
  • React

4. Link Pseudo-Classes

These four pseudo-classes target the lifecycle states of <a> elements. The order you write them matters.

L :link
V :visited
H :hover
A :active

The mnemonic is LoVe HAte. Because of the cascade, writing them in any other order can cause later states to be silently overridden.

/* Always write in LVHA order */
a:link    { color: #4a90e2; }             /* unvisited */
a:visited { color: #9b6bc8; }             /* already visited */
a:hover   { color: #2e6db5; text-decoration: underline; }
a:active  { color: #e06c75; }             /* during click */
✦ Hover and click the links below

Hover any link to see hover color. Click and hold to see active color.

✅ :visited privacy limits

For security reasons, browsers limit which CSS properties can be set in :visited — only color, background-color, border-color, outline-color, and a few others. You can't read the computed style via JavaScript either.

5. Advanced: :is(), :where(), :has()

Modern CSS additions that make selectors cleaner, reduce repetition, and unlock patterns that were previously impossible.

:is() Group selectors, reduce repetition

:is() takes a selector list and matches any of them. It's shorthand for writing the same declaration block for many selectors. Its specificity is the specificity of the most specific item in the list.

/* Without :is() — repetitive */
header h1, header h2, header h3,
main h1,   main h2,   main h3 {
  color: #4a90e2;
}

/* With :is() — clean */
:is(header, main) :is(h1, h2, h3) {
  color: #4a90e2;
}

/* Hover on multiple elements */
:is(a, button, .link):hover {
  text-decoration: underline;
}
✦ All headings below are styled with a single :is() rule

H3 heading inside the box

This paragraph is a sibling but not matched.

H4 heading — also matched by :is(h3, h4, h5)

Both headings got their color from one rule.

:where() Zero-specificity grouping

Exactly like :is() — but its specificity is always zero. This makes it ideal for base/reset styles that should be easy to override anywhere.

/* Base resets — specificity 0, easy to override */
:where(h1, h2, h3, h4) {
  margin-top: 1.5em;
  line-height: 1.25;
}

/* A component lib using :where() won't fight your styles */
:where(.card) {
  padding: 1rem;
  border-radius: 8px;
}

/* This wins because specificity > 0 */
.card { padding: 0.5rem; }
📝 :is() vs :where()

Use :is() when you want the selector to have normal specificity. Use :where() for resets and default styles where you need downstream code to easily override without fighting the cascade.

:has() The parent selector

:has() is a game-changer — it lets you select a parent based on what's inside it. Previously impossible without JavaScript. Browser support is now excellent (Chrome 105+, Safari 15.4+, Firefox 121+).

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

/* Form that has an invalid input */
form:has(input:invalid) .submit-btn {
  opacity: 0.5;
  pointer-events: none;
}

/* Nav item that contains the current page link */
nav li:has(a[aria-current="page"]) {
  background: rgba(74, 144, 226, 0.1);
  border-radius: 6px;
}

/* Heading followed immediately by a paragraph */
h2:has(+ p) {
  margin-bottom: 0.25rem;
}
✅ When to reach for :has()
  • Styling a container based on its children's state
  • Disabling UI based on form validity
  • Layout tweaks when optional elements (images, badges) are present

6. Common Mistakes

:hover on touch devices

Touch screens have no cursor, so :hover either fires on tap (and stays stuck) or doesn't fire at all. Never rely on hover to reveal content — it must also be accessible without hovering. Use @media (hover: hover) to limit hover effects to pointer devices.

Removing :focus without a replacement

outline: none alone breaks keyboard navigation for users who rely on it. Always replace with a custom ring using box-shadow or outline with a different style. Use :focus-visible to avoid rings on mouse clicks.

:nth-child() counting wrong

:nth-child() counts all children of the parent, not just ones matching the tag. If a ul has an h3 as its first child, then li:nth-child(1) won't match anything — the first li is actually child 2. Switch to :nth-of-type() in mixed-element parents.

Writing LVHA in the wrong order

If you write a:hover before a:visited, the hover state will never show on visited links because :visited comes later in the cascade and wins. Always use: :link:visited:hover:active.

:valid/:invalid firing on page load

Empty required inputs are technically :invalid as soon as the page loads. This means your error styles will show before the user has typed anything. Always use :invalid:not(:placeholder-shown) or :invalid:not(:focus) to wait for user interaction.

Over-using :not() for cascade hacks

Writing button:not(.special) to undo a rule is often a sign you should restructure your CSS. Prefer defining your base styles broadly and adding modifier classes, rather than excluding specific elements after the fact.

Summary

📚 What you learned
  • Pseudo-class — single colon, targets element state; pseudo-element — double colon, creates virtual content
  • :hover / :active — cursor interaction; always add transition for smooth animations
  • :focus — never remove without replacement; use :focus-visible for keyboard-only rings
  • :checked — pure CSS toggles via adjacent sibling combinator (+ / ~)
  • :disabled / :enabled — style form states; set cursor: not-allowed on disabled
  • :valid / :invalid — add :not(:placeholder-shown) to avoid premature error states
  • :first-child / :last-child — remove edge margins/borders without extra classes
  • :nth-child()odd/even for zebra striping; An+B formula for more control
  • :not() — cleaner than writing overrides; avoid using it as a cascade hack
  • LVHA rule — link, visited, hover, active — always in this order
  • :is() / :where() — group selectors; :where() has zero specificity for easy overrides
  • :has() — the parent selector; style containers based on their children's state