CSS Pseudo-Classes
Learn every CSS pseudo-class with visual examples: :hover, :focus, :active, :visited, :checked, :nth-child, :first-child, :last-child, :not(), :is(), and :where() explained clearly.
In the previous article, we covered attribute selectors and how they let you target elements based on what’s written inside their HTML tags. That was already pretty powerful. But there’s a whole other dimension of targeting elements in CSS that feels almost like magic the first time you see it.
You’re not always styling things as they sit there, completely still. You’re styling them when something happens. When a user moves their mouse over a button. When they click inside a form field. When they check a checkbox. When a link has already been visited. CSS gives you tools to react to all of this, and they’re called pseudo-classes.
On top of that, pseudo-classes let you target elements based on their position in the page structure. The first item in a list. Every third row in a table. The last card in a grid. All of that, without adding a single extra class to your HTML.
This is one of the lessons where CSS really starts feeling alive. Let’s get into it.
01What Is a CSS Pseudo-Class?
A pseudo-class is a keyword you attach to a selector using a single colon :. It targets an element not based on what it is in the HTML, but based on its current state or its position in the document tree.
The syntax looks like this:
selector:pseudo-class {
property: value;
}
/* Examples: */
a:hover { color: blue; }
input:focus { border-color: purple; }
li:first-child { font-weight: bold; }
button:not(.disabled) { cursor: pointer; }
Notice the single colon. Later in this series, when we cover pseudo-elements, those use a double colon ::. One colon is always a pseudo-class, two colons are a pseudo-element. That’s an easy way to tell them apart.
There are three main groups of pseudo-classes, and we’re going to cover each one in turn:
- Interaction pseudo-classes: they respond to what the user is doing (:hover, :focus, :active, :visited, :checked)
- Structural pseudo-classes: they target elements by their position in the DOM (:first-child, :last-child, :nth-child)
- Logical pseudo-classes: they help you write smarter, more flexible selectors (:not(), :is(), :where())
02Interaction Pseudo-Classes: Responding to the User
These are the ones you’ll use constantly. Every time you want a button to change when hovered, or an input to show a glow when focused, you’re reaching for interaction pseudo-classes.
:hover, Reacting to the Mouse Cursor
The :hover pseudo-class applies styles when the user moves their mouse over an element. It’s probably the most commonly used pseudo-class in all of CSS.
/* A simple button hover effect */
.btn {
background: #6366f1;
color: white;
padding: 12px 24px;
border-radius: 8px;
transition: background 0.2s ease;
}
.btn:hover {
background: #4f46e5;
}
The transition property is what makes the change smooth instead of snapping instantly. It’s not part of the pseudo-class itself, but without it, :hover effects look jarring. Always pair hover styles with a transition on the base element.
Here’s a live example. Hover over these cards:
Design
Hover to see the border glow and lift effect kick in.
Performance
That smooth scale + shadow? Pure CSS :hover at work.
Interaction
No JavaScript. No libraries. Just a single pseudo-class.
One thing to keep in mind with :hover: it doesn’t exist on touchscreen devices. There’s no “hover” on a phone. So never put functionality that users need to see behind a hover state. Use it for decorative enhancements only.
:hover. If you put transition only inside :hover, the element will transition in but snap back instantly when you un-hover. A common beginner mistake.:focus, Showing What’s Currently Active
The :focus pseudo-class activates when an element receives keyboard or mouse focus. Form inputs, buttons, links, and anything with tabindex can receive focus.
This pseudo-class is incredibly important for accessibility. When users navigate your site with a keyboard (which is how screen readers work, how power users work, and how anyone with a motor disability navigates the web), they need to clearly see which element is focused. Removing focus styles with outline: none and replacing them with nothing is one of the most common accessibility mistakes in CSS.
/* Don't do this alone */
input:focus {
outline: none; /* You removed the browser's only visible focus indicator */
}
/* Do this instead */
input:focus {
outline: none;
border-color: #6366f1;
box-shadow: 0 0 0 4px rgba(99, 102, 241, 0.2);
}
Click inside both inputs below and see the difference:
Hard to tell if this input is focused at all.
Immediate, clear visual confirmation of focus.
There’s also :focus-visible, which is worth knowing about. It’s like :focus but only applies when the browser determines the focus indicator should be shown, basically when navigating by keyboard rather than by mouse click. It lets you skip the focus ring for mouse users while keeping it for keyboard users. But for now, :focus is where you start.
:active, Catching the Press
The :active pseudo-class fires during the very moment an element is being clicked or pressed. It’s fleeting, only active for the split second between mouse-down and mouse-up.
button {
background: #6366f1;
transform: translateY(0);
transition: transform 0.1s ease, box-shadow 0.1s ease;
box-shadow: 0 4px 14px rgba(99, 102, 241, 0.35);
}
button:hover {
box-shadow: 0 6px 20px rgba(99, 102, 241, 0.45);
}
button:active {
transform: translateY(1px) scale(0.97);
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.2);
}
That tiny downward shift and reduced shadow creates a “physical press” feeling. It’s a small detail, but users notice it. It makes buttons feel real and tactile. Press these:
Hold the mouse button down to see :active clearly.
The order of pseudo-classes on links matters, by the way. You’ll hear the mnemonic LVHA: Link, Visited, Hover, Active. When you’re styling anchor tags, define them in that order to avoid specificity conflicts. We covered how specificity works in depth in our cascade article, and the same rules apply here.
:visited, Remembering Clicked Links
The :visited pseudo-class targets links that the browser has already recorded as visited by the user. By default browsers turn them purple. You can override that.
a:link {
color: #a5b4fc; /* Unvisited links */
}
a:visited {
color: #64748b; /* Visited links, a muted tone */
}
Here’s what that looks like in practice. The links below show unvisited vs already-visited states:
-
Introduction to CSS
Likely visited -
CSS Comments Guide
Likely visited -
CSS Attribute Selectors
Likely visited
If you’ve visited these pages, the browser will show them in the visited color. This is determined entirely by your own browser history.
:visited can change. You can only use it to change color-related properties: color, background-color, border-color, outline-color, and a few others. You cannot change width, height, opacity, or transform with :visited. This is intentional. If websites could read back what :visited looks like, they could detect your browsing history.:checked, Styling Selected Form Controls
The :checked pseudo-class applies to checkboxes, radio buttons, and <option> elements inside a <select> that are currently selected.
On its own, :checked is limited because you can’t easily style the checkbox itself beyond a few basic properties. But pair it with the adjacent sibling combinator + or ~ (which we covered in our grouping and combining selectors article), and you can build completely custom toggle switches with pure CSS:
/* Hide the real checkbox */
.toggle input { display: none; }
/* The visible switch track */
.toggle .track {
width: 46px;
height: 26px;
background: rgba(255, 255, 255, 0.1);
border-radius: 999px;
position: relative;
cursor: pointer;
transition: background 0.22s ease;
}
/* The knob */
.toggle .track::after {
content: "";
position: absolute;
width: 20px;
height: 20px;
background: white;
border-radius: 50%;
top: 3px;
left: 3px;
transition: transform 0.22s ease;
}
/* When the hidden checkbox is checked, style the sibling .track */
.toggle input:checked + .track {
background: #6366f1;
}
.toggle input:checked + .track::after {
transform: translateX(20px);
}
See it in action, and actually interact with these. They’re built entirely with CSS, no JavaScript at all:
Custom checkboxes via :checked
Toggle switches, CSS-only
Custom radio buttons
No JavaScript. No extra libraries. Just :checked paired with combinators. This is the kind of thing that makes you genuinely appreciate CSS once you see it working.
03Structural Pseudo-Classes: Targeting by Position
These pseudo-classes don’t care about user interaction. They target elements based on where they live inside their parent, counting from the top or using a pattern formula.
:first-child and :last-child
:first-child selects an element that is the very first child of its parent. :last-child selects the last one. Simple as that.
/* Style the first item in any list */
li:first-child {
border-top: none;
color: #a5b4fc;
}
/* Remove the border from the last item */
li:last-child {
border-bottom: none;
}
/* Or combine both for a first-child-only style */
.card:first-child {
border-top-left-radius: 16px;
border-top-right-radius: 16px;
}
- Dashboard :first-child
- Analytics
- Projects
- Team Members
- Settings :last-child
A practical use: removing the bottom border from the last item in a list (so you don’t get a double border when the list sits inside a bordered container). You’ll find yourself writing li:last-child { border-bottom: none; } fairly often.
There’s also :first-of-type and :last-of-type. These are different in an important way: :first-child checks position among siblings of all types, while :first-of-type checks position among siblings of the same element type. For example, if a <div> has an <h2> followed by three <p> tags, then the <p> is not the :first-child of that div (the <h2> is), but it is the :first-of-type among <p> elements.
:nth-child(), The Pattern Selector
This is where structural pseudo-classes get really powerful. :nth-child() lets you target elements by their position using a formula.
/* Target only the 3rd item */
li:nth-child(3) { color: red; }
/* Every odd item: 1, 3, 5, 7... */
li:nth-child(odd) { background: rgba(255, 255, 255, 0.03); }
/* Every even item: 2, 4, 6, 8... */
li:nth-child(even) { background: rgba(255, 255, 255, 0.06); }
/* Every 3rd item: 3, 6, 9, 12... */
li:nth-child(3n) { border-left: 3px solid #6366f1; }
/* Every 3rd, starting from 1: 1, 4, 7, 10... */
li:nth-child(3n+1) { color: #a5b4fc; }
/* First 3 items only */
li:nth-child(-n+3) { font-weight: bold; }
The formula is An+B where A is the cycle size and B is the offset. n counts from 0 upward. If that sounds abstract, click the patterns below and watch which boxes get highlighted:
click a button above:nth-child formula selects.The most common use of :nth-child is zebra-striping table rows: alternating odd and even rows with slightly different backgrounds, so long data tables are much easier to read. It’s one of those things that used to require JavaScript or manually adding CSS classes to every other row. Now it’s one line of CSS.
There’s also :nth-last-child() which counts from the end instead of the beginning, and :nth-of-type() which counts only matching element types. The syntax and formula are identical, just counting differently.
04Logical Pseudo-Classes: Smarter Selector Writing
These three pseudo-classes don’t match states or positions. They change how you write and combine selectors. They’re tools for cleaner, more maintainable CSS.
:not(), Excluding Elements from a Rule
:not() is the exclusion selector. It applies styles to every element that does not match the selector inside the parentheses.
/* Style all buttons except disabled ones */
button:not(.disabled) {
cursor: pointer;
background: #6366f1;
}
/* Add a border between items, but not after the last one */
li:not(:last-child) {
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
/* Style all inputs except hidden ones */
input:not([type="hidden"]) {
border: 1.5px solid rgba(255, 255, 255, 0.1);
}
In the demo below, the rule .pc-not-card:not(.featured) dims all non-featured cards. Only the ones marked with the class featured stay fully visible:
The CSS rule is: .pc-not-card:not(.featured) { opacity: 0.35; }
That second example up there, li:not(:last-child), is particularly useful. Instead of adding a border to all list items and then removing it from the last one, you just apply the border to everything except the last. Clean, and very readable.
Modern browsers also support multiple selectors inside :not(), like :not(.disabled, .hidden). That’s the modern syntax. It’s widely supported now and worth using.
:is(), Grouping Selectors Without Repeating Yourself
Here’s a scenario you’ll run into all the time. You want to apply the same style to headings inside multiple different containers:
/* Without :is() — repetitive */
.card h1,
.card h2,
.card h3,
.sidebar h1,
.sidebar h2,
.sidebar h3 {
color: #a5b4fc;
}
/* With :is() — clean and readable */
:is(.card, .sidebar) :is(h1, h2, h3) {
color: #a5b4fc;
}
Both of those do exactly the same thing. The second version is just dramatically cleaner. :is() takes a list of selectors and acts as if you wrote each combination out separately.
One important thing to know about :is(): its specificity is determined by the most specific selector inside it. If you write :is(#header, .nav, p), the entire thing gets the specificity of #header because that’s the most specific selector in the list. That’s sometimes surprising. Keep it in mind, especially if you find styles applied with :is() are unexpectedly overriding things.
:where(), Same Power, Zero Specificity Cost
:where() does exactly the same thing as :is() in terms of how it groups selectors. You can use it in the exact same way. The only difference is that :where() always has zero specificity, no matter what’s inside it.
/* :is() carries the specificity of its most specific argument */
:is(#header, .nav, p) a {
color: blue; /* Specificity: 1,0,0 (because of #header) */
}
/* :where() always contributes zero specificity */
:where(#header, .nav, p) a {
color: blue; /* Specificity: 0,0,1 (just the 'a' tag) */
}
Here’s a side-by-side look at how they compare:
:is()
h2 {
color: #a5b4fc;
}
Good for grouping when you’re okay with the specificity it carries. Use when you want a solid match that’s hard to override accidentally.
:where()
h2 {
color: #a5b4fc;
}
Perfect for base/reset styles and theme layers you want to be easily overridden. The zero specificity means a simple class selector will always win.
The practical way to think about it: use :is() when you want the grouping to behave like you typed out all the selectors manually. Use :where() when you’re writing default styles or resets that you explicitly want to be easy to override.
We’ll go much deeper on both of these, along with the very powerful :has() selector, in a dedicated article later in this series. For now, just know they exist and what they’re for.
05A Pseudo-Class Cheat Sheet
Here’s a quick reference you can come back to:
| Pseudo-class | What it targets | Most common use |
|---|---|---|
| :hover | Element under the cursor | Button/card hover effects |
| :focus | Focused element (keyboard/click) | Form input focus rings |
| :active | Element being clicked right now | Button press animations |
| :visited | Links already in browser history | Muting visited link colors |
| :checked | Checked checkboxes and radios | Custom toggles and checkboxes |
| :first-child | First child of its parent | Removing top borders/margins |
| :last-child | Last child of its parent | Removing bottom borders |
| :nth-child(n) | Children matching a position formula | Table row striping, grid highlights |
| :not(selector) | Elements that do NOT match | Excluding disabled/hidden elements |
| :is(list) | Any element matching the list | Grouping long, repetitive selectors |
| :where(list) | Same as :is(), but zero specificity | Base styles and resets |
06A Few Things Worth Knowing Before You Move On
Pseudo-classes can be chained. There’s nothing stopping you from writing input:focus:not([disabled]) to target focused inputs that are also not disabled. They stack.
They also inherit specificity the same way regular selectors do. A class + pseudo-class like .btn:hover has higher specificity than just button:hover. If you’re hitting a wall where your hover styles aren’t applying, this is worth checking. The cascade article covers exactly how to debug this.
And a small but important thing: pseudo-classes on selectors still inherit properties from parent elements, just like normal CSS. The inheritance article is worth revisiting if you ever find a pseudo-class style not showing up where you expect it.
Next in the series, we’re covering pseudo-elements, which use the double colon ::. They let you insert and style content that doesn’t even exist in your HTML, like ::before and ::after. That opens up a whole world of visual effects without touching your markup.
Pseudo-classes are everywhere in real projects. Once you’ve written :hover and :focus a few times, they become automatic. The structural ones like :nth-child feel a bit more like puzzles at first, but that’s exactly the kind of thing that becomes second nature once you’ve solved it a few times. Keep building things. That’s how it sticks.
CSS Pseudo-Classes
Loading challenge…
Ready to Practice?
Select a difficulty level above to load your challenge.