Skip to content

Pseudo-Classes and Pseudo-Elements

beginner12 min read

Selecting What Doesn't Exist in the DOM

Pseudo-classes select elements based on state — hovered, focused, first child, checked. Pseudo-elements select parts of elements that don't exist as DOM nodes — the first line, the first letter, generated content before or after. Together, they let you style things that no class or ID can reach.

But the really exciting part? The modern additions -- :has(), :is(), :where(), and improved :not() -- fundamentally changed what's possible in CSS. :has() alone eliminated entire categories of JavaScript that existed solely to toggle parent styles based on child content.

Mental Model

Pseudo-classes are conditional selectors — they match elements when a condition is true (hovered, first child, checked). Think of them as CSS if statements. Pseudo-elements are virtual elements — they create styleable targets that aren't in the HTML. ::before and ::after inject content as if you added a <span> without touching the markup.

State Pseudo-Classes

Interactive States

a:hover { color: blue; }      /* Mouse over */
a:active { color: red; }      /* Being clicked */
a:focus { outline: 2px solid; } /* Focused (keyboard or click) */
a:focus-visible { outline: 2px solid; } /* Focused via keyboard only */

button:disabled { opacity: 0.5; }
input:enabled { background: white; }
Info

Always use :focus-visible instead of :focus for visible focus indicators. :focus triggers on mouse click too, which creates unwanted outlines. :focus-visible only shows the outline when the user is navigating with a keyboard — matching user expectation.

Form States

input:required { border-left: 3px solid blue; }
input:optional { border-left: 3px solid gray; }
input:valid { border-color: green; }
input:invalid { border-color: red; }
input:placeholder-shown { /* Styles when placeholder is visible */ }
input:checked + label { font-weight: bold; }

/* :user-invalid — only invalid AFTER user interaction */
input:user-invalid { border-color: red; }
/* Better UX than :invalid which shows errors before the user types */

Structural Pseudo-Classes

li:first-child { font-weight: bold; }
li:last-child { border-bottom: none; }
li:nth-child(odd) { background: #f5f5f5; }
li:nth-child(3n+1) { color: blue; } /* Every 3rd starting from 1st */
li:only-child { margin: 0 auto; }

/* The of S syntax — filter what nth-child considers */
li:nth-child(2 of .important) { /* 2nd element that has .important */ }

p:first-of-type { font-size: 1.25em; }
p:last-of-type { margin-bottom: 0; }
tr:nth-of-type(even) { background: #fafafa; }

:root { /* Matches the <html> element */ }
:empty { display: none; } /* Elements with no children/text */

The Modern Four: :has(), :is(), :where(), :not()

:has() -- The Parent Selector

This is the one that had CSS developers celebrating. :has() selects an element based on what it contains. CSS finally has upward selection:

/* Card with an image gets no padding at top */
.card:has(img) {
  padding-top: 0;
}

/* Form group with an invalid input gets red border */
.form-group:has(input:invalid) {
  border-left: 3px solid red;
}

/* Figure with a figcaption gets different margin */
figure:has(figcaption) {
  margin-bottom: 2rem;
}

/* Style a label when its sibling input is focused */
label:has(+ input:focus) {
  color: blue;
}

/* Page-level: body when a modal is open */
body:has(.modal.open) {
  overflow: hidden;
}

:is() — Grouping with Specificity

:is() matches any element that matches any of its arguments. Its specificity equals the most specific argument:

/* Without :is() — repetitive */
article h1, article h2, article h3,
section h1, section h2, section h3 {
  color: #333;
}

/* With :is() — clean */
:is(article, section) :is(h1, h2, h3) {
  color: #333;
}
/* Specificity: (0, 0, 2) — element + element */

:where() — Grouping with Zero Specificity

Identical matching to :is() but contributes zero specificity:

/* Base styles that are easy to override */
:where(h1, h2, h3, h4, h5, h6) {
  margin-top: 0;
  line-height: 1.2;
}
/* Specificity: (0, 0, 0) — any selector can override */

/* CSS resets use :where() for this exact reason */
:where(ul, ol) {
  list-style: none;
  padding: 0;
}

:not() — Exclusion

/* All links except those with the .nav-link class */
a:not(.nav-link) { text-decoration: underline; }

/* Multiple exclusions */
input:not([type="submit"]):not([type="reset"]) {
  border: 1px solid #ccc;
}

/* Modern: multiple arguments */
input:not([type="submit"], [type="reset"]) {
  border: 1px solid #ccc;
}
Common Trap

:not() doesn't do what you might expect with descendants. div:not(.parent) p does NOT mean "paragraphs not inside .parent". It means "paragraphs inside any div that doesn't have the .parent class." CSS selectors can't express "not a descendant of" — that requires :has() on the ancestor instead.

Pseudo-Elements: ::before and ::after

These are like invisible helper elements you can conjure out of thin air. Pseudo-elements create virtual elements in the render tree:

.quote::before {
  content: open-quote;
  font-size: 2em;
  color: #ccc;
}

.required::after {
  content: " *";
  color: red;
}

/* Decorative line */
.section-title::after {
  content: "";
  display: block;
  width: 60px;
  height: 3px;
  background: blue;
  margin-top: 0.5rem;
}

Rules:

  • content is required — even if empty (content: "")
  • They're children of the element, not siblings
  • They don't work on void elements (<img>, <input>, <br>)
  • They're not in the DOM — JavaScript can't select them
  • Screen readers may announce the content value

Other Pseudo-Elements

p::first-line { font-variant: small-caps; }
p::first-letter { font-size: 3em; float: left; }
::selection { background: #b3d4fc; color: #000; }
::placeholder { color: #999; font-style: italic; }
input::file-selector-button { /* Style file input button */ }

/* Marker for list items */
li::marker { color: blue; font-weight: bold; }
Execution Trace
Parse selector
.card:has(img:first-child)
Browser parses the :has() argument
Match children
Check if .card contains img:first-child
Browser walks the subtree
Match parent
If child matches, the parent (.card) is selected
This is upward selection
Compute specificity
(0, 1, 1) — .card + img from :has()
:has() takes its argument's specificity
Apply styles
Styles applied to .card, not the img
The subject of :has() is the element before it

Production Scenario: Dynamic Styling Without JavaScript

You might be surprised how much interactivity you can build with zero JS.

/* Toggle dark mode based on a checkbox — no JS */
#dark-toggle:checked ~ main {
  --bg: #1a1a2e;
  --text: #e0e0e0;
}

/* Show/hide content based on radio buttons */
#tab1:checked ~ .content .panel-1 { display: block; }
#tab2:checked ~ .content .panel-2 { display: block; }

/* Style parent based on child state (the :has() revolution) */
.form-field:has(input:focus) {
  border-color: blue;
  box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2);
}

.form-field:has(input:user-invalid) {
  border-color: red;
}
What developers doWhat they should do
Using :focus for visible focus indicators (shows on mouse click)
Mouse users don't need focus rings. :focus-visible matches only when the browser determines a focus indicator is needed.
Use :focus-visible — only shows focus ring for keyboard navigation
Putting content in ::before/::after that's essential for understanding
Screen reader support for ::before/::after content is inconsistent, and the content isn't in the DOM.
Generated content should be decorative. Essential content belongs in HTML.
Thinking :not(.parent) p means 'p not inside .parent'
CSS selectors match elements, not relationships. :not() filters the element it's attached to.
:not(.parent) p means 'p inside any element that doesn't have .parent class'
Forgetting content: '' on decorative ::before/::after
Without content property, the pseudo-element doesn't exist in the render tree at all
content is required for the pseudo-element to be generated
Quiz
What does body:has(.modal.open) { overflow: hidden; } do?
Quiz
What is the specificity of :where(.card, #hero) :is(h1, .title)?
Quiz
Which elements can ::before and ::after be used on?
Key Rules
  1. 1:has() enables parent selection — style any element based on what it contains
  2. 2:where() provides zero-specificity matching — perfect for resets and defaults
  3. 3:is() provides grouping with specificity equal to its most specific argument
  4. 4Use :focus-visible instead of :focus for keyboard-only focus indicators
  5. 5::before/::after require the content property and don't work on void elements