Module: CSS
CSS·033·5 MIN READ

033: Pseudo-Classes, Pseudo-Elements, and Interaction States

TOPICS COVERED: 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:

css
button:hover {}
input:checked {}
li:first-child {}

A pseudo-element addresses a part or generated box associated with an element:

css
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

css
.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

css
.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

css
.field:focus-within {
  border-color: #2563eb;
}

A parent can react when one of its descendants has focus.

Active

css
.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

css
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

html
<label class="choice">
  <input type="checkbox" name="updates">
  Email me product updates
</label>
css
.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

css
.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

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

css
.button:not([disabled]) {
  cursor: pointer;
}

:is()

css
.prose :is(h2, h3, h4) {
  line-height: 1.2;
}

:is() takes the specificity of its most specific argument.

:where()

css
: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()

html
<label class="field">
  <span>Email</span>
  <input type="email" required>
</label>
css
.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:

css
.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.

css
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

css
.external-link::after {
  content: " ↗";
}

Generated content is presentation. Essential instructions should live in HTML.

::marker

css
.checklist li::marker {
  color: #16a34a;
  font-weight: 700;
}

::selection

css
::selection {
  background: #fde68a;
  color: #111827;
}

::first-letter and ::first-line

Useful for editorial treatments:

css
.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

html
<p class="status" data-status="success">
  Payment completed
</p>
css
.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

html
<details class="faq">
  <summary>Can I cancel anytime?</summary>
  <p>Yes. Your access remains active through the paid period.</p>
</details>
css
.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.

css
/* 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:

css
.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

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

css
.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.

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

css
/* 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:

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

  1. inspect the element in DevTools;
  2. force :hover, :focus, or another supported state;
  3. confirm the selector matches;
  4. inspect specificity and source/layer order;
  5. check whether a pseudo-element has content;
  6. 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 ::before text as the only accessible label.
  • Confusing :active with 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 newer of syntax is used deliberately.
  • Raising specificity accidentally with long functional-selector arguments.

Practice set

  1. Style a navigation link for hover, focus-visible, current-page, and visited states.
  2. Build a checklist using ::marker, not generated Unicode bullets.
  3. Use :focus-within to highlight an entire form-field wrapper.
  4. Write a :has() rule that styles a card only when it contains an image.
  5. Refactor a repeated selector list with :is() and then with :where(). Explain the specificity difference.
  6. 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