🎯 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
Selects an element in a particular state. The element already exists in the DOM.
Pseudo-element
Targets a virtual part of an element, or inserts content that isn't in the HTML.
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;
}
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;
}
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 */
}
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;
}
Clicking with a mouse shows no ring. Press Tab to see the outline appear.
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 */
}
: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;
}
: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);
}
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 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 */
| 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, 3 | First 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(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;
}
- 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.
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 any link to see hover color. Click and hold to see active color.
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;
}
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; }
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;
}
- 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
- Pseudo-class — single colon, targets element state; pseudo-element — double colon, creates virtual content
- :hover / :active — cursor interaction; always add
transitionfor 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-allowedon 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/evenfor zebra striping;An+Bformula 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