033: Pseudo-Classes, Pseudo-Elements, and Interaction States
Learning outcomes
By the end, you can distinguish pseudo-classes from pseudo-elements; style interaction, form, location, and structural states; use :is(), :where(), :not(), and :has() deliberately; use ::before, ::after, ::marker, and ::selection safely; and build state styles that remain accessible for keyboard and touch users.
Prerequisites and retrieval
Retrieve selector matching from 019 and cascade reasoning from 020. Pseudo selectors are not JavaScript events. They are selector conditions evaluated by the browser.
Mental model: ask the browser about a state or a part
A pseudo-class asks whether an element currently has a condition:
button:hover {}
input:checked {}
li:first-child {}
A pseudo-element addresses a part or generated box associated with an element:
p::first-line {}
li::marker {}
.badge::before {}
Single-colon syntax exists for some historical pseudo-elements, but modern authoring should use double colons for pseudo-elements.
Interaction pseudo-classes
Hover
.button:hover {
background: #1d4ed8;
}
Hover is an enhancement. Do not hide required controls until hover because many touch and keyboard users do not have a reliable hover state.
Focus and focus-visible
.button:focus-visible {
outline: 3px solid #f59e0b;
outline-offset: 3px;
}
:focus matches any focused element. :focus-visible lets the browser apply heuristics for when a visible focus indicator is especially appropriate, commonly keyboard interaction.
Focus-within
.field:focus-within {
border-color: #2563eb;
}
A parent can react when one of its descendants has focus.
Active
.button:active {
transform: translateY(1px);
}
:active is usually momentary while the control is being activated. It is not a persistent “selected tab” state.
Form-state pseudo-classes
input:disabled {
opacity: 0.65;
}
input:checked + label {
font-weight: 700;
}
input:required {
border-inline-start: 3px solid #2563eb;
}
input:user-invalid {
border-color: #b91c1c;
}
Use HTML validity and disabled state as the source of truth where possible.
Worked example: custom checkbox accent without rebuilding the control
<label class="choice">
<input type="checkbox" name="updates">
Email me product updates
</label>
.choice {
display: flex;
align-items: start;
gap: 0.6rem;
}
.choice input {
inline-size: 1.2rem;
block-size: 1.2rem;
accent-color: #2563eb;
}
Native controls already provide accessibility and interaction behavior. Prefer accent-color before replacing them with complex pseudo-element replicas.
Structural pseudo-classes
.list > :first-child {}
.list > :last-child {}
.list > :nth-child(odd) {}
.list > :nth-child(3n + 1) {}
.card:only-child {}
Worked example: zebra rows without extra classes
tbody tr:nth-child(even) {
background: #f8fafc;
}
Do not rely on stripe color alone to communicate row meaning. Striping is a reading aid, not data semantics.
Functional pseudo-classes
:not()
.button:not([disabled]) {
cursor: pointer;
}
:is()
.prose :is(h2, h3, h4) {
line-height: 1.2;
}
:is() takes the specificity of its most specific argument.
:where()
:where(.prose h2, .prose h3, .prose h4) {
margin-block-start: 1.5em;
}
:where() contributes zero specificity, making it useful for defaults designed to be easy to override.
:has()
<label class="field">
<span>Email</span>
<input type="email" required>
</label>
.field:has(input:user-invalid) {
color: #991b1b;
}
:has() is sometimes described as a parent selector, but it is more general: it lets an element match based on relative selectors.
Another example:
.card:has(.card__media) {
grid-template-columns: 8rem 1fr;
}
Use it when the DOM relationship itself is meaningful. Do not use :has() merely because you are unwilling to add a stable component modifier.
Link/location pseudo-classes
a:link {}
a:visited {}
a:any-link {}
Browsers deliberately restrict which properties can reveal visited-history information. Treat :visited as a small visual distinction, not a state you can inspect programmatically.
Pseudo-elements
::before and ::after
.external-link::after {
content: " ↗";
}
Generated content is presentation. Essential instructions should live in HTML.
::marker
.checklist li::marker {
color: #16a34a;
font-weight: 700;
}
::selection
::selection {
background: #fde68a;
color: #111827;
}
::first-letter and ::first-line
Useful for editorial treatments:
.article-intro::first-letter {
font-size: 3em;
font-weight: 700;
}
Do not make typography so elaborate that reading order or line height breaks.
Worked example: CSS-only status decoration with semantic HTML
<p class="status" data-status="success">
Payment completed
</p>
.status {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.status::before {
content: "";
inline-size: 0.65rem;
aspect-ratio: 1;
border-radius: 50%;
background: #64748b;
}
.status[data-status="success"]::before {
background: #16a34a;
}
The word “Payment completed” carries the meaning. The dot is redundant decoration, so generated content is appropriate.
Worked example: disclosure hover/focus treatment
<details class="faq">
<summary>Can I cancel anytime?</summary>
<p>Yes. Your access remains active through the paid period.</p>
</details>
.faq {
border-block-end: 1px solid #cbd5e1;
}
.faq summary {
padding-block: 1rem;
cursor: pointer;
}
.faq summary:hover {
background: #f8fafc;
}
.faq summary:focus-visible {
outline: 3px solid #2563eb;
outline-offset: -3px;
}
.faq[open] summary {
font-weight: 700;
}
Here the HTML open state is matched through an attribute selector. CSS styles the state without having to recreate disclosure behavior.
Deep dive: specificity and state selectors
Pseudo-classes participate in selector specificity. The important modern exception is :where(), whose arguments contribute zero specificity.
/* specificity comes from .toolbar and :is()'s most specific argument */
.toolbar :is(a, button, [role="button"]) {
min-block-size: 2.75rem;
}
/* :where() does not add specificity */
:where(.article, .docs) :where(h2, h3) {
scroll-margin-block-start: 5rem;
}
Use :where() for low-specificity defaults and :is() when grouping selectors should keep normal specificity.
:not() and :has() also take selector lists:
.card:not(.card--featured) {
border-color: var(--border-muted);
}
.field:has(input:user-invalid) {
border-inline-start: 0.25rem solid var(--danger);
}
:has() is especially powerful because it lets a parent respond to descendants without JavaScript. Do not use it as an excuse for deeply coupled selectors. Prefer a component-local relationship.
Structural selectors beyond first-child
.table-row:nth-child(even) {
background: var(--surface-subtle);
}
.card:nth-child(3n + 1) {
--accent: var(--accent-a);
}
.item:nth-child(-n + 3) {
font-weight: 600;
}
Modern :nth-child(... of selector) can count only matching siblings:
.list > :nth-child(odd of .visible-item) {
background: var(--surface-subtle);
}
This is useful when hidden or differently classified siblings should not affect the pattern.
Deep dive: forms and interaction states
A robust form usually needs more than :valid and :invalid.
.field input:focus-visible {
outline: 0.2rem solid var(--focus);
outline-offset: 0.15rem;
}
.field:focus-within {
background: var(--surface-focus);
}
.field input:user-invalid {
border-color: var(--danger);
}
.field input:user-valid {
border-color: var(--success);
}
:user-invalid and :user-valid are useful because they are intended to reflect user interaction rather than immediately showing every untouched required field as invalid.
Do not remove focus outlines globally:
/* harmful */
*:focus {
outline: none;
}
If you replace a default focus indicator, the replacement must remain clearly visible.
Generated content is decoration unless proven otherwise
::before and ::after are excellent for decorative marks:
.external-link::after {
content: "↗";
margin-inline-start: 0.25em;
}
Do not put essential instructions, prices, error text, or button labels only in generated content. Critical information belongs in HTML.
Debugging selector state
When a state style does not appear:
- inspect the element in DevTools;
- force
:hover,:focus, or another supported state; - confirm the selector matches;
- inspect specificity and source/layer order;
- check whether a pseudo-element has
content; - verify the HTML state is actually present (
disabled,checked,open, etc.).
This separates “the state never exists” from “the rule exists but loses the cascade.”
Common mistakes
- Removing focus outlines because they “look ugly”.
- Building essential actions that appear only on hover.
- Using
::beforetext as the only accessible label. - Confusing
:activewith an application's persistent active state. - Writing extremely expensive/fragile
:has()selectors across remote ancestors. - Using
:nth-child()while forgetting it counts siblings, not “elements of this class” unless newerofsyntax is used deliberately. - Raising specificity accidentally with long functional-selector arguments.
Practice set
- Style a navigation link for hover, focus-visible, current-page, and visited states.
- Build a checklist using
::marker, not generated Unicode bullets. - Use
:focus-withinto highlight an entire form-field wrapper. - Write a
:has()rule that styles a card only when it contains an image. - Refactor a repeated selector list with
:is()and then with:where(). Explain the specificity difference. - Create an invalid form state using native validation and
:user-invalid.
Recap
Pseudo-classes query state or structure. Pseudo-elements style generated/partial boxes. They are powerful because they let CSS react to the document without adding a class for every transient state. That power should preserve semantic HTML, keyboard visibility, and source-of-truth state.
Official references
-
MDN: Pseudo-classes — https://developer.mozilla.org/en-US/docs/Web/CSS/Pseudo-classes
-
MDN:
:has()— https://developer.mozilla.org/en-US/docs/Web/CSS/:has -
MDN: Pseudo-classes — https://developer.mozilla.org/en-US/docs/Web/CSS/Pseudo-classes
-
MDN: Pseudo-elements — https://developer.mozilla.org/en-US/docs/Web/CSS/Pseudo-elements
-
MDN:
:has()— https://developer.mozilla.org/en-US/docs/Web/CSS/:has -
Selectors Level 4 — https://drafts.csswg.org/selectors/
