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 :hover or :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 uses :hover (state). Another uses ::before (a generated piece).
Pseudo power

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, :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.

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.

Example link 1
Example link 2

Note: :visited styling 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.

  • :required matches inputs with the required attribute.
  • :optional matches inputs without required.
  • :valid and :invalid depend on the input type (like email) and any constraints (like required).
.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 :invalid and :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:

  • :disabled and :enabled match whether the field can be interacted with.
  • :checked matches checked checkboxes and selected radio buttons.
  • :indeterminate is 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: :indeterminate usually 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-child matches the first child of its parent.
  • :last-child matches the last child of its parent.
  • :only-child matches an element that is the only child.
  • :empty matches 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 :empty can “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: odd or even.
  • A formula: an+b like 3n or 2n+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).

1
2
3
4
5
6
7
8
9

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().

A link

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 A
Card 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:

  1. The state never happens: :hover won’t show on touch devices the same way, :focus won’t happen if you can’t focus the element, :valid won’t happen if constraints aren’t set.
  2. You’re targeting the wrong element: label:checked won’t work because labels can't get checked, inputs do. You want input:checked + label.
  3. Specificity or order issues: A later rule may override your pseudo-class styling.
  4. Structural mismatch: :first-child only matches if it is literally the first child node that counts as an element child.
  5. Browser restrictions: :visited has 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 :invalid and :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().