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
Bring back the selector-matching ideas from 019 and the cascade reasoning from 020. A pseudo-selector is not a JavaScript event. It is a condition the browser evaluates while matching selectors against the document.
Mental model: ask the browser about a state or a part
A pseudo-class asks the browser whether an element currently meets a condition:
button:hover {}
input:checked {}
li:first-child {}
A pseudo-element targets a part of an element, or a generated box associated with it:
p::first-line {}
li::marker {}
.badge::before {}
Some older pseudo-elements also accept single-colon syntax for historical reasons. In new author CSS, use the double-colon form so the distinction is explicit.
Interaction pseudo-classes
Hover
.button:hover {
background: #1d4ed8;
}
Treat hover as an enhancement, not as the only way to reach an important control. Touch users may never have a reliable hover state, and keyboard users navigate through focus instead.
Focus and focus-visible
.button:focus-visible {
outline: 3px solid #f59e0b;
outline-offset: 3px;
}
:focus matches an element whenever it has focus. :focus-visible gives the browser room to decide when a visible focus indicator is especially useful, which commonly means keyboard navigation. The browser's heuristic is doing the filtering; it does not mean focus can be ignored.
Focus-within
.field:focus-within {
border-color: #2563eb;
}
:focus-within lets a container respond when any descendant has focus. It is useful when the field wrapper, rather than just the input, needs to communicate the current interaction state.
Active
.button:active {
transform: translateY(1px);
}
:active normally lasts only while the control is being activated. It describes that brief press or activation moment, not a tab or navigation item that remains selected.
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;
}
When the browser already knows a control's validity or disabled state, use that HTML state as the source of truth instead of duplicating it in a class or script.
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;
}
The native checkbox already supplies keyboard behavior, semantics, and accessibility support. Try accent-color before replacing that control with a complicated pseudo-element replica.
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 make the stripe color carry the meaning of a row. Alternating backgrounds can make a table easier to read, but they are a visual aid rather than data semantics.
Functional pseudo-classes
:not()
.button:not([disabled]) {
cursor: pointer;
}
:is()
.prose :is(h2, h3, h4) {
line-height: 1.2;
}
:is() groups alternatives in one selector. Its specificity is the specificity of its most specific argument, so the grouped form can still be stronger than a reader might expect from looking only at the surrounding selector.
:where()
:where(.prose h2, .prose h3, .prose h4) {
margin-block-start: 1.5em;
}
:where() also groups alternatives, but contributes zero specificity. That makes it a good fit for defaults that consumers or later component rules should be able to override easily.
:has()
<label class="field">
<span>Email</span>
<input type="email" required>
</label>
.field:has(input:user-invalid) {
color: #991b1b;
}
People often call :has() a parent selector, but that description is narrower than the feature. It lets an element match when a relative selector matches from that element, so the relationship can express more than simple parent-child selection.
Another example:
.card:has(.card__media) {
grid-template-columns: 8rem 1fr;
}
Use :has() when the DOM relationship is itself the condition you care about. Do not reach for it just to avoid adding a stable, intentional component modifier.
Link/location pseudo-classes
a:link {}
a:visited {}
a:any-link {}
Browsers intentionally limit the properties that can expose visited-history information. Use :visited for a modest visual distinction; it is not a state that your code can inspect programmatically.
Pseudo-elements
::before and ::after
.external-link::after {
content: " ↗";
}
Generated content belongs to presentation. Put essential instructions and meaning in the HTML, where they remain part of the document's content and semantics.
::marker
.checklist li::marker {
color: #16a34a;
font-weight: 700;
}
::selection
::selection {
background: #fde68a;
color: #111827;
}
::first-letter and ::first-line
These are useful for editorial treatments:
.article-intro::first-letter {
font-size: 3em;
font-weight: 700;
}
Keep such typography subordinate to readable content. An elaborate treatment is not successful if it disrupts reading order or produces unusable line height.
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 text “Payment completed” is the meaningful status. The dot repeats that meaning as decoration, so generated content is appropriate here. If the dot disappeared, the user would still have the status text.
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;
}
The open state is an HTML attribute, so an attribute selector can style it directly. CSS changes the presentation of the disclosure without recreating the disclosure behavior in JavaScript.
Deep dive: specificity and state selectors
Pseudo-classes contribute to selector specificity. The key modern exception here is :where(): the selectors inside it 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;
}
Choose :where() for low-specificity defaults. Choose :is() when you want to group selectors while retaining their normal specificity behavior.
:not() and :has() accept selector lists as well:
.card:not(.card--featured) {
border-color: var(--border-muted);
}
.field:has(input:user-invalid) {
border-inline-start: 0.25rem solid var(--danger);
}
:has() is particularly useful because it allows a parent to react to a descendant without JavaScript. That power can also produce tightly coupled selectors. Keep the relationship local to the component instead of making distant ancestors depend on deep markup.
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;
}
The modern :nth-child(... of selector) form can count only siblings that match a selector:
.list > :nth-child(odd of .visible-item) {
background: var(--surface-subtle);
}
That matters when hidden or differently classified siblings should not shift the alternating pattern.
Deep dive: forms and interaction states
A form that communicates well usually needs more than a blanket :valid or :invalid rule.
.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 interaction by the user. That avoids immediately presenting every untouched required field as an error before the person has had a chance to fill it in.
Do not globally remove focus outlines:
/* harmful */
*:focus {
outline: none;
}
If you replace the browser's indicator, the replacement still needs to be clearly visible.
Generated content is decoration unless proven otherwise
::before and ::after work well for decorative marks:
.external-link::after {
content: "↗";
margin-inline-start: 0.25em;
}
Do not put essential instructions, prices, error messages, or button labels only in generated content. Critical information belongs in the HTML.
Debugging selector state
When a state rule does not appear, work from the element outward:
- inspect the element in DevTools;
- force
:hover,:focus, or another supported state; - confirm that the selector matches;
- inspect specificity and source/layer order;
- check whether the pseudo-element has
content; - verify that the HTML state is actually present (
disabled,checked,open, and so on).
This process distinguishes two different failures: the state may never exist, or the rule may match but lose in the cascade.
Common mistakes
- Removing focus outlines because they “look ugly”.
- Making essential actions appear only on hover.
- Using
::beforetext as the only accessible label. - Confusing
:activewith an application's persistent active state. - Writing extremely expensive or fragile
:has()selectors across remote ancestors. - Using
:nth-child()while forgetting that it counts siblings, not “elements of this class” unless the newerofsyntax is deliberately used. - Accidentally raising specificity 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 or partial boxes. They are useful because CSS can respond to the document without requiring a class for every temporary state. Use that flexibility while keeping semantic HTML, keyboard-visible interaction, and the browser or markup state as the source of truth.
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/
