A CSS pseudo-class styles an element based on a state it is in or a position it occupies, so you can react to hover, keyboard focus, a checked box or an item's place in a list without writing JavaScript or adding a class to your HTML. This page maps exactly what hover, focus and nth-child match, how specificity settles conflicts between them, and where the traps sit.
What is a CSS pseudo-class, and why is it not a class?
A class is something you author: you write class="btn" into the HTML and the browser treats that token as a permanent label attached to the element. A pseudo-class is a question the browser asks about an element continuously, and answers for you. The state lives inside the browser — the pointer is over this link, this button holds keyboard focus, this checkbox is ticked, this list item is the third one — and your CSS simply describes what should be true when the answer is yes.
That inversion is the whole point. Instead of shipping a second copy of your markup for every state, you write one rule per state and the document stays clean. It also means a pseudo-class can never be reached from JavaScript the way a class can: document.querySelector('.is-open') finds nothing, because .is-open was never in the DOM. The browser evaluates the match at style time and keeps the answer internal.
Pseudo-classes are not inherited either. If a div matches :hover, its children do not inherit that state - every element is judged on its own terms.
<nav class="tabs" aria-label="Sections">
<a href="#overview">Overview</a>
<a href="#specs">Specs</a>
<a href="#reviews">Reviews</a>
</nav>
<form class="signup">
<label for="email">Work email</label>
<input id="email" type="email" required placeholder="[email protected]">
<button type="submit">Join</button>
</form>What do :hover and :focus actually match?
:hover matches the element the pointer is over and every ancestor of it. It never matches a sibling. That single rule explains the two selectors beginners always mix up. In .card:hover .title, the card is hovered and the title sits inside it, so the title changes. In .card .title:hover, only the title itself has to be under the pointer for the change to happen. On a touch screen there is no pointer, so hover styles never apply — never hang an interaction on hover alone.
:focus applies to the one element that currently holds focus, and only if that element is focusable at all. A div with no tabindex cannot receive focus, so :focus will never match it. Links, buttons, text inputs and anything carrying a tabindex are focusable. Clicking a link also gives it focus in current evergreen browsers, which is why a link can sit in the :hover and the :focus state at the same moment.
.tabs a {
color: #44586c;
text-decoration: none;
padding: 0.4rem 0.7rem;
border-radius: 6px;
}
.tabs a:hover {
color: #10243a;
background: #e7f0f8;
}
.tabs a:focus-visible {
outline: 3px solid #2f6fed;
outline-offset: 2px;
}Now the cascade, precisely. The rule .tabs a carries a specificity of (0,1,1): one class in the class column, one type selector in the type column. The rule .tabs a:hover carries (0,2,1), because a pseudo-class counts in the same column as a class. Higher specificity is decided before source order is even consulted, so the hover colour replaces the resting colour with no !important and no ordering tricks. Adding .tabs a:focus-visible keeps the same (0,2,1) figure, but because it only matches in the focus-visible state, the two rules never fight over the same element.
How does :nth-child count through a list?
:nth-child(an+b) counts element children of the same parent, starting at position one, and applies to whatever element happens to sit in that position regardless of its tag name. That last part is the trap. Insert a heading or a wrapper above a list and every li shifts one place, so rules written against nth-child now land on the wrong items and nothing tells you why.
:nth-of-type counts only the siblings that share the same tag name, so it survives those insertions. If a list holds only li elements, li:nth-of-type(3) still means the third list item even after a stray div appears above it. When in doubt about which to use, ask whether your rule should survive a change in the surrounding markup: if yes, use nth-of-type.
.steps li {
padding: 0.6rem 0;
border-bottom: 1px solid #dde5ec;
}
.steps li.current {
border-inline-start: 3px solid #2f6fed;
}
/* Every third element child of the list */
.steps li:nth-child(3n) {
background: #f4f8fc;
}
/* Counts only li siblings, so it survives a wrapper element */
.steps li:nth-of-type(3) {
color: #10243a;
}
.steps li:last-child {
border-bottom: 0;
}The formula covers every case: :nth-child(2n) is every even item, :nth-child(2n+1) every odd item, and :nth-child(-n+3) the first three.
Can a pseudo-class react to what someone types?
Form controls carry their own family of states. :checked matches a ticked checkbox or a selected radio button. :disabled and :enabled separate locked controls from usable ones. :required and :optional key off the presence of the required attribute. :placeholder-shown matches while the placeholder is still on screen, which is a reliable signal that the field is empty. :valid and :invalid report the result of constraint validation.
Here is the trap worth memorising. A required, empty email field is invalid from the moment the page loads, so input:invalid paints a red border on a form nobody has touched yet, and the first thing the visitor sees is an accusation. The honest selector is :user-invalid, which only matches once the user has interacted with the field and left it in a bad state. It is supported in current evergreen browsers. Pair it with :user-valid for the confirmation colour, so the field does not congratulate someone for typing nothing.
.signup input {
border: 1px solid #b9c6d2;
padding: 0.55rem 0.7rem;
border-radius: 6px;
}
.signup input:user-invalid {
border-color: #b3261e;
}
.signup input:user-valid {
border-color: #1f7a4d;
}
.signup input:placeholder-shown {
border-color: #b9c6d2;
}
.signup button:disabled {
cursor: not-allowed;
opacity: 0.55;
}:placeholder-shown still earns its place, but for neutral treatment rather than validation: a field showing its placeholder is an empty field, and softer styling is often right while the visitor is still typing.
Is :focus the same as :focus-visible?
No, and the difference is about manners rather than mechanics. :focus matches whenever an element holds focus, however it got there. :focus-visible matches only when the user agent decides a focus indicator should be shown, which in practice means keyboard and assistive-technology navigation, and not a mouse click on a button. Text inputs are the exception: they keep their indicator because the caret position itself has to be visible.
So draw the ring with :focus-visible, and quiet it for pointer interaction with :focus:not(:focus-visible). The two rules never match the same element at once, so the ring appears in exactly the state you asked for.
/* Keyboard users get a visible ring */
a:focus-visible,
button:focus-visible {
outline: 3px solid #2f6fed;
outline-offset: 2px;
border-radius: 4px;
}
/* Pointer users get no ring — but only in this exact state */
a:focus:not(:focus-visible) {
outline: none;
}Whatever you decide, replace the outline rather than deleting it: removing it without putting something else in its place is the most common accessibility regression in hand-written CSS. Keep the outline and restyle it, or add a box-shadow alongside it rather than instead of it.
A quick reference for the pseudo-classes you will actually use
| Pseudo-class | Matches when | Typical job |
|---|---|---|
:hover | The pointer is over the element or one of its ancestors | Hover affordances and previews |
:focus | The element holds keyboard focus | Focus state on any focusable control |
:focus-visible | The user agent wants a focus ring shown | Visible keyboard rings |
:active | The element is being pressed | Press feedback |
:checked | A checkbox or radio button is selected | Custom toggles and selected rows |
:disabled and :enabled | The control is locked or usable | Dimming unavailable fields |
:user-invalid and :user-valid | Validation result after interaction | Honest field feedback |
:placeholder-shown | The placeholder is still visible | Empty-field styling |
:first-child and :last-child | Position among element siblings | First and last row rules |
:nth-child(an+b) | The formula position among element siblings | Striping, every third item |
:nth-of-type(an+b) | The formula position among same-tag siblings | Striping that survives markup edits |
:not(selector) | The element does not match the argument | Exceptions and targeted overrides |
What to remember about pseudo-classes
- A pseudo-class is a live question the browser asks. You never write it into the HTML, and no script can read the answer back.
:hovercovers the hovered element and its ancestors, never its siblings, and it never fires on a touch screen.- A pseudo-class adds one step to the class column of specificity, so
a:hoveroutranksawithout any!important. :nth-childcounts all element siblings while:nth-of-typecounts siblings of the same tag. Choose the one that survives markup edits.- Field validation belongs on
:user-invalid, so a form never looks broken before the first keystroke. - Style the focus ring with
:focus-visible, and never deleteoutlinewithout putting a replacement in its place.
Try it yourself: take a page with a three-item navigation list and a sign-up form. Give the navigation a hover background and a visible keyboard ring, then stripe the list rows with :nth-child(odd). Insert a paragraph above the list and watch the striping shift onto the wrong items — switch to :nth-of-type and confirm it holds. In the form, give a required email field a red border through :user-invalid, and check in the browser that a pristine field stays neutral until you type and clear it. Finally, unplug the mouse and tab through the page: every control you reach must show a ring.