What are CSS pseudo-classes?
A pseudo-class is a keyword you add to a selector to target an element in a particular state (hovered, focused, checked, invalid…), in a particular position (first child, every 3rd item…), or matching a special condition (contains something, matches a list, is the URL target…).
The syntax is simple: you add : followed by the pseudo-class name, like a:hover or
input:checked.
Think of pseudo-classes as “temporary classes the browser applies for you” based on what’s happening.
CSS pseudo-classes selector syntax
Here are a few common patterns you’ll use constantly:
-
Element + state:
button:hover,input:focus -
Class + state:
.card:hover,.field:focus-within -
Combinators + state:
nav a:hover,.list > li:first-child -
Functional pseudo-classes:
:not(...),:is(...),:where(...),:nth-child(...),:has(...)
.card:hover {
transform: translateY(-4px);
}
.card:focus-within {
outline: 3px solid #0077b6;
}
.list > li:first-child {
background: #fff3bf;
}
*,
::before,
::after {
box-sizing: border-box;
}
.wrap {
max-width: 980px;
padding: 18px;
font-family: system-ui, Arial, sans-serif;
display: grid;
gap: 14px;
}
.row {
display: grid;
gap: 12px;
grid-template-columns: 1fr;
}
.card {
border: 3px solid #111;
border-radius: 16px;
padding: 14px;
background: #fff;
box-shadow: 0 12px 0 #111;
transition: transform 180ms ease;
display: grid;
gap: 10px;
}
.card a {
display: inline-block;
padding: 8px 12px;
border-radius: 999px;
background: #f2f2f2;
color: #111;
text-decoration: none;
}
.list {
border: 3px solid #111;
border-radius: 16px;
overflow: hidden;
background: #fff;
padding: 0;
margin: 0;
list-style: none;
}
.list > li {
padding: 10px 12px;
border-top: 2px solid #111;
}
.list > li:first-child {
border-top: 0;
}
.hint {
border: 2px dashed #111;
border-radius: 14px;
padding: 10px 12px;
background: #fff;
}
Hover the card, then click into the input to see focus-within.Interactive card A link inside
- First item (should highlight with snippet 3)
- Second item
- Third item
- Fourth item
Try Snippet 1 then hover the Interactive card. Switch to Snippet 2, then focus on the input to see the focus-within effect.
CSS pseudo-classes vs pseudo-elements
This is one of the first “CSS vocabulary” speed bumps:
-
Pseudo-classes use
:and describe a state or condition, like:hoveror:first-child. -
Pseudo-elements use
::and create or target a piece of the element, like::before,::after,::first-line.
Quick rule of thumb: pseudo-classes ask “when?” or “which one?” and pseudo-elements ask “which part?”.
.tag:hover {
transform: translateY(-2px);
}
.tag::before {
content: "★ ";
}
.tag:hover::before {
content: "✨ ";
}
*,
::before,
::after {
box-sizing: border-box;
}
.wrap {
max-width: 980px;
padding: 18px;
font-family: system-ui, Arial, sans-serif;
display: grid;
gap: 12px;
}
.tag {
display: inline-block;
border: 3px solid #111;
border-radius: 999px;
padding: 10px 14px;
background: #fff;
box-shadow: 0 10px 0 #111;
width: fit-content;
transition: transform 160ms ease;
}
.note {
border: 2px dashed #111;
border-radius: 14px;
padding: 10px 12px;
background: #fff;
}
Try it: hover the pill. One snippet usesPseudo power:hover(state). Another uses::before(a generated piece).
Learn more about pseudo-elements in the CSS ::before and ::after Pseudo-Elements Interactive Tutorial.
CSS pseudo-classes list cheat sheet
Here’s a beginner-friendly “pseudo-classes list” grouped by what they’re usually used for. You don’t need to memorize everything, but it helps to know what’s possible.
User interaction pseudo-classes
:hover,:active:focus,:focus-visible,:focus-within
Link pseudo-classes
:link,:visited
Form state pseudo-classes
:enabled,:disabled,:read-only,:read-write:required,:optional:valid,:invalid:in-range,:out-of-range:checked,:indeterminate:placeholder-shown
Structural pseudo-classes
:first-child,:last-child,:only-child:nth-child(),:nth-last-child():first-of-type,:last-of-type,:only-of-type:nth-of-type(),:nth-last-of-type():empty
Functional pseudo-classes
:not():is(),:where():has()
Document and URL pseudo-classes
:root:target:lang()
CSS pseudo-classes examples
Let’s make this practical. Each section below has interactive playgrounds, and each snippet highlights a real-world pattern you’ll use in UI work.
Link states: :link and :visited
Links have built-in states. :link targets links you haven’t visited, and :visited targets
links you have visited.
Browsers intentionally restrict which properties you can change on :visited (privacy reasons), so keep
it simple: color is the classic.
a:link {
color: #0077b6;
}
a:visited {
color: #6a4c93;
}
a:hover {
text-decoration: none;
}
*,
::before,
::after {
box-sizing: border-box;
}
.wrap {
max-width: 980px;
padding: 18px;
font-family: system-ui, Arial, sans-serif;
display: grid;
gap: 12px;
}
.panel {
border: 3px solid #111;
border-radius: 16px;
padding: 14px;
background: #fff;
box-shadow: 0 12px 0 #111;
display: grid;
gap: 10px;
}
a {
font-weight: 700;
}
small {
opacity: 0.8;
}
Try clicking one link. Your browser may show it as visited.
Note::visitedstyling is intentionally limited by browsers.
Learn more in the CSS Link Interactive Tutorial.
Hover and active: :hover and :active
:hover is “the pointer is over me.”
:active is “I’m currently being pressed/clicked.”
If you want buttons to feel snappy, this pair is your bread and butter.
.button:hover {
transform: translateY(-2px);
}
.button:active {
transform: translateY(2px);
box-shadow: 0 6px 0 #111;
}
.button:hover .spark {
opacity: 1;
transform: translateY(0);
}
*,
::before,
::after {
box-sizing: border-box;
}
.wrap {
max-width: 980px;
padding: 18px;
font-family: system-ui, Arial, sans-serif;
}
.button {
font-size: 1rem;
border: 3px solid #111;
border-radius: 16px;
padding: 14px 16px;
background: #fff;
box-shadow: 0 12px 0 #111;
display: inline-flex;
gap: 10px;
align-items: center;
cursor: pointer;
user-select: none;
transition: transform 140ms ease, box-shadow 140ms ease;
}
.spark {
font-size: 14px;
opacity: 0.25;
transform: translateY(2px);
transition: opacity 140ms ease, transform 140ms ease;
}
.note {
margin-top: 10px;
opacity: 0.85;
}
Hover to lift. Click and hold to see
:active(with snippet 2 active).
Learn more in the CSS :hover Pseudo-Class Interactive Tutorial.
Focus: :focus and :focus-visible
:focus happens when an element is focused (clicking into an input, tabbing to a button).
:focus-visible is a more refined version: it typically shows when focus should be visible (like
keyboard navigation), but may not show for mouse clicks depending on the browser.
For accessibility, you generally want a visible focus style for keyboard users. :focus-visible is great
for that.
.field input:focus {
outline: 3px solid #111;
outline-offset: 3px;
}
.field input:focus-visible {
outline: 3px solid #0077b6;
outline-offset: 3px;
}
.field input:focus:not(:focus-visible) {
outline: 0;
}
*,
::before,
::after {
box-sizing: border-box;
}
.wrap {
max-width: 980px;
padding: 18px;
font-family: system-ui, Arial, sans-serif;
display: grid;
gap: 12px;
}
.field {
border: 3px solid #111;
border-radius: 16px;
padding: 14px;
background: #fff;
box-shadow: 0 12px 0 #111;
display: grid;
gap: 10px;
}
.field label {
display: grid;
gap: 6px;
font-weight: 700;
}
.field input {
border: 2px solid #111;
border-radius: 12px;
padding: 10px 12px;
font: inherit;
}
.help {
opacity: 0.85;
}
Snippet 1:
:focus. Snippet 2::focus-visible. Snippet 3: hide focus ring for non-keyboard focus.
:focus-within (style the parent when a child is focused)
:focus-within is a favorite for form UI: it matches a container if any descendant has focus.
That means you can highlight an entire field wrapper when the input inside is active.
.group:focus-within {
border-color: #0077b6;
box-shadow: 0 12px 0 #0077b6;
}
.group:focus-within .badge {
transform: translateY(-2px);
}
.group:focus-within .group-title {
color: #0077b6;
}
*,
::before,
::after {
box-sizing: border-box;
}
.wrap {
max-width: 980px;
padding: 18px;
font-family: system-ui, Arial, sans-serif;
}
.group {
border: 3px solid #111;
border-radius: 16px;
padding: 14px;
background: #fff;
box-shadow: 0 12px 0 #111;
transition: border-color 160ms ease, box-shadow 160ms ease;
display: grid;
gap: 10px;
}
.group-title {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
font-weight: 800;
}
.badge {
border: 2px solid #111;
border-radius: 999px;
padding: 6px 10px;
background: #f2f2f2;
transition: transform 160ms ease;
font-size: 12px;
}
.group input,
.group select {
border: 2px solid #111;
border-radius: 12px;
padding: 10px 12px;
font: inherit;
width: 100%;
}
.grid {
display: grid;
gap: 10px;
grid-template-columns: 1fr;
}
@media (min-width: 760px) {
.grid {
grid-template-columns: 1fr 1fr;
}
}
Contact details focus-within
Forms: :required, :optional, :valid, :invalid
Forms come with built-in “truthiness.” You can style inputs based on whether they are required, and whether their current value is valid.
-
:requiredmatches inputs with therequiredattribute. -
:optionalmatches inputs withoutrequired. -
:validand:invaliddepend on the input type (likeemail) and any constraints (likerequired).
.field input:required {
border-style: solid;
}
.field input:invalid {
border-color: #ef233c;
}
.field input:valid {
border-color: #2d6a4f;
}
*,
::before,
::after {
box-sizing: border-box;
}
.wrap {
max-width: 980px;
padding: 18px;
font-family: system-ui, Arial, sans-serif;
display: grid;
gap: 12px;
}
.panel {
border: 3px solid #111;
border-radius: 16px;
padding: 14px;
background: #fff;
box-shadow: 0 12px 0 #111;
display: grid;
gap: 12px;
}
.field {
display: grid;
gap: 6px;
}
.field label {
font-weight: 800;
}
.field input {
border: 3px dashed #111;
border-radius: 12px;
padding: 10px 12px;
font: inherit;
}
.tip {
opacity: 0.85;
margin: 0;
}
Type something invalid, then a valid email to see
:invalidand:valid.This one matches
:optional(we’re not styling it in snippets, but it exists).
Forms: :in-range and :out-of-range
When an input has constraints like min and max, the browser can tell you whether the
current value is inside the allowed range.
That’s what :in-range and :out-of-range do.
input:out-of-range {
border-color: #ef233c;
}
input:out-of-range + .status::after {
content: "Out of range";
}
input:in-range {
border-color: #2d6a4f;
}
input:out-of-range + .status::after {
content: "Out of range";
}
*,
::before,
::after {
box-sizing: border-box;
}
.wrap {
max-width: 980px;
padding: 18px;
font-family: system-ui, Arial, sans-serif;
}
.panel {
border: 3px solid #111;
border-radius: 16px;
padding: 14px;
background: #fff;
box-shadow: 0 12px 0 #111;
display: grid;
gap: 10px;
}
label {
font-weight: 800;
display: grid;
gap: 8px;
}
input {
border: 3px solid #111;
border-radius: 12px;
padding: 10px 12px;
font: inherit;
width: min(420px, 100%);
}
.status {
opacity: 0.85;
}
.status::after {
content: "In range";
}
Status:
Try typing 10 or 120.
Forms: :enabled, :disabled, :checked, :indeterminate
These are the classic “form UI” pseudo-classes:
:disabledand:enabledmatch whether the field can be interacted with.:checkedmatches checked checkboxes and selected radio buttons.:indeterminateis a special checkbox state (often used for “Select all” patterns). It’s usually set by JavaScript, but we can still demonstrate how it would look.
input:disabled + label {
opacity: 0.5;
}
input:checked + label {
font-weight: 900;
}
input:checked + label .dot {
transform: scale(1);
}
*,
::before,
::after {
box-sizing: border-box;
}
.wrap {
max-width: 980px;
padding: 18px;
font-family: system-ui, Arial, sans-serif;
}
.panel {
border: 3px solid #111;
border-radius: 16px;
padding: 14px;
background: #fff;
box-shadow: 0 12px 0 #111;
display: grid;
gap: 12px;
}
.row {
display: grid;
gap: 10px;
}
.item {
display: flex;
gap: 10px;
align-items: center;
}
.item input {
width: 18px;
height: 18px;
}
.item label {
display: inline-flex;
gap: 10px;
align-items: center;
cursor: pointer;
}
.pill {
border: 2px solid #111;
border-radius: 999px;
padding: 6px 10px;
background: #f2f2f2;
font-size: 12px;
}
.dot {
width: 12px;
height: 12px;
border: 2px solid #111;
border-radius: 999px;
background: #fff;
box-shadow: inset 0 0 0 6px #111;
transform: scale(0);
transition: transform 160ms ease;
}
Note:
:indeterminateusually needs JavaScript to set. The selector exists so you can style that “mixed” state when it happens.
:placeholder-shown (and why it feels like magic)
:placeholder-shown matches an input when it’s currently showing placeholder text (usually when it’s
empty).
It’s handy for “floating label” vibes, or small hints that disappear once the user types.
.field input:placeholder-shown {
background: #fff3bf;
}
.field input:not(:placeholder-shown) {
background: #fff;
}
.field input:placeholder-shown + .msg::after {
content: "Type something to remove the placeholder";
}
*,
::before,
::after {
box-sizing: border-box;
}
.wrap {
max-width: 980px;
padding: 18px;
font-family: system-ui, Arial, sans-serif;
}
.field {
border: 3px solid #111;
border-radius: 16px;
padding: 14px;
background: #fff;
box-shadow: 0 12px 0 #111;
display: grid;
gap: 10px;
width: min(520px, 100%);
}
.field label {
font-weight: 900;
}
.field input {
border: 2px solid #111;
border-radius: 12px;
padding: 10px 12px;
font: inherit;
}
.msg {
opacity: 0.85;
}
.msg::after {
content: "Nice. Placeholder is gone.";
}
Structural selectors: :first-child, :last-child, :only-child, :empty
Structural pseudo-classes let you target elements based on where they sit in the DOM tree. They’re fantastic for list styling, spacing rules, and “special-case the first/last item” UI polish.
:first-childmatches the first child of its parent.:last-childmatches the last child of its parent.:only-childmatches an element that is the only child.:emptymatches an element with no children (and no text nodes).
.list > li:first-child {
background: #fff3bf;
}
.list > li:last-child {
background: #e9ecef;
}
.badge:empty::before {
content: "No notifications";
}
*,
::before,
::after {
box-sizing: border-box;
}
.wrap {
max-width: 980px;
padding: 18px;
font-family: system-ui, Arial, sans-serif;
display: grid;
gap: 12px;
}
.list {
border: 3px solid #111;
border-radius: 16px;
overflow: hidden;
padding: 0;
margin: 0;
list-style: none;
background: #fff;
width: min(520px, 100%);
}
.list > li {
padding: 10px 12px;
border-top: 2px solid #111;
}
.list > li:first-child {
border-top: 0;
}
.badge {
border: 3px solid #111;
border-radius: 999px;
padding: 10px 12px;
background: #fff;
box-shadow: 0 12px 0 #111;
width: fit-content;
}
.note {
opacity: 0.85;
}
- First item
- Middle item
- Last item
The badge above is empty, so
:emptycan “fill in” a message using::before.
Learn more in the CSS First Child Interactive Tutorial and in the CSS Last Child Interactive Tutorial.
:nth-child() (the most useful structural power tool)
:nth-child() lets you select items by a pattern. The pattern can be:
- A number:
:nth-child(3)selects the 3rd child. - Keywords:
oddoreven. - A formula:
an+blike3nor2n+1.
And :nth-last-child() works the same way, but counts from the end.
.grid > .tile:nth-child(odd) {
transform: rotate(-1deg);
}
.grid > .tile:nth-child(even) {
transform: rotate(1deg);
}
.grid > .tile:nth-child(3n) {
outline: 3px solid #0077b6;
}
*,
::before,
::after {
box-sizing: border-box;
}
.wrap {
max-width: 980px;
padding: 18px;
font-family: system-ui, Arial, sans-serif;
}
.grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
width: min(720px, 100%);
}
.tile {
border: 3px solid #111;
border-radius: 16px;
padding: 14px;
background: #fff;
box-shadow: 0 12px 0 #111;
text-align: center;
font-weight: 900;
transition: transform 160ms ease;
}
.hint {
opacity: 0.85;
margin: 0 0 10px;
}
Snippet 1: odd items. Snippet 2: even items. Snippet 3: every 3rd item (
3n).123456789
Learn more in the CSS :nth-child() Interactive Tutorial.
:not(), :is(), and :where() (selector list superpowers)
These three are “functional” pseudo-classes that take selectors inside parentheses. They help you express logic cleanly.
:not() excludes things
:not(...) matches everything except what’s inside. Great for “style all buttons except the
primary one.”
Learn more in the CSS :not() Interactive Tutorial.
:is() matches any of these
:is(...) lets you group a selector list without repeating yourself.
It can also affect specificity (it tends to “take the specificity” of the most specific selector inside).
:where() is like :is(), but with low specificity
:where(...) is awesome for writing base styles that are easy to override, because its specificity is
always zero.
.button:not(.primary) {
background: #f2f2f2;
}
.card :is(a, button) {
text-decoration: underline;
}
.card :where(h3, p) {
margin: 0;
}
*,
::before,
::after {
box-sizing: border-box;
}
.wrap {
max-width: 980px;
padding: 18px;
font-family: system-ui, Arial, sans-serif;
display: grid;
gap: 12px;
}
.card {
border: 3px solid #111;
border-radius: 16px;
padding: 14px;
background: #fff;
box-shadow: 0 12px 0 #111;
display: grid;
gap: 10px;
width: min(720px, 100%);
}
.actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.button {
border: 3px solid #111;
border-radius: 999px;
padding: 10px 14px;
background: #fff;
cursor: pointer;
font: inherit;
font-weight: 800;
}
.button.primary {
background: #fff3bf;
}
Selector helpers
This card contains a link and buttons. The snippets use
:not(),:is(), and:where().
Learn more in the CSS :is() and :where() Pseudo-Classes Interactive Tutorial.
:has() (parent selection with conditions)
:has() is the “finally!” selector: it lets you select an element based on what it contains.
A classic use is styling a card if it has a certain child, or styling a label if the input is
checked.
Example idea: highlight a card when it contains a checked checkbox.
.card:has(input:checked) {
border-color: #2d6a4f;
box-shadow: 0 12px 0 #2d6a4f;
}
.card:has(input:checked) .status::after {
content: "Selected";
}
.card:has(input:not(:checked)) .status::after {
content: "Not selected";
}
*,
::before,
::after {
box-sizing: border-box;
}
.wrap {
max-width: 980px;
padding: 18px;
font-family: system-ui, Arial, sans-serif;
}
.grid {
display: grid;
gap: 12px;
grid-template-columns: 1fr;
}
@media (min-width: 760px) {
.grid {
grid-template-columns: 1fr 1fr;
}
}
.card {
border: 3px solid #111;
border-radius: 16px;
padding: 14px;
background: #fff;
box-shadow: 0 12px 0 #111;
transition: border-color 160ms ease, box-shadow 160ms ease;
display: grid;
gap: 10px;
}
.header {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
font-weight: 900;
}
.status {
opacity: 0.85;
}
.toggle {
display: flex;
gap: 10px;
align-items: center;
}
.toggle input {
width: 18px;
height: 18px;
}
Card ACard B
Learn more in the CSS :has Pseudo-Class Interactive Tutorial.
:root and :target (page-level pseudo-classes)
:root matches the root element of the document (usually html).
It’s commonly used for global styling decisions.
:target matches the element whose id matches the URL fragment (the part after
#).
This is great for “CSS-only” highlights when navigating to anchors.
See the Pen Untitled by Element How (@elementhow) on CodePen.
Why my pseudo-class doesn’t work
If a pseudo-class “does nothing,” it’s usually one of these:
-
The state never happens:
:hoverwon’t show on touch devices the same way,:focuswon’t happen if you can’t focus the element,:validwon’t happen if constraints aren’t set. -
You’re targeting the wrong element:
label:checkedwon’t work because labels can't get checked, inputs do. You wantinput:checked + label. - Specificity or order issues: A later rule may override your pseudo-class styling.
-
Structural mismatch:
:first-childonly matches if it is literally the first child node that counts as an element child. -
Browser restrictions:
:visitedhas limited styling for privacy reasons.
Debug tip: temporarily add something loud like outline: 4px solid to confirm your selector is matching
anything at all.
Wrap-up and next steps
Pseudo-classes are one of the biggest “level ups” in CSS, because they let your styles react to user interaction, document structure, and UI state without adding extra classes everywhere.
-
For UI work, start with
:hover,:active,:focus-visible,:focus-within, and form states like:invalidand:checked. -
For layout polish, practice
:first-child,:last-child, and:nth-child(). -
For cleaner selectors, use
:not(),:is(), and:where(). -
When you’re ready for “wow, that’s legal?” selectors, reach for
:has().
