Pseudo-Classes and Pseudo-Elements
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.
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; }
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;
}
: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:
contentis 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
contentvalue
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; }
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 do | What 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 |
- 1:has() enables parent selection — style any element based on what it contains
- 2:where() provides zero-specificity matching — perfect for resets and defaults
- 3:is() provides grouping with specificity equal to its most specific argument
- 4Use :focus-visible instead of :focus for keyboard-only focus indicators
- 5::before/::after require the content property and don't work on void elements