Pseudo-Classes and Pseudo-Elements
Introduction
Pseudo-classes and pseudo-elements let you style state, structure, and generated fragments without adding extra HTML nodes. They are essential for interactive UI feedback, cleaner selectors, and small decorative patterns. This chapter focuses on practical usage and the accessibility guardrails you should follow.
Prerequisites
- Basic CSS selectors and specificity
- Familiarity with form controls and interactive elements
- Basic understanding of focus and accessibility
Pseudo-Class vs Pseudo-Element
Quick distinction:
- Pseudo-class (
:hover,:focus,:nth-child) targets an existing element in a specific state/condition - Pseudo-element (
::before,::after,::marker) targets a conceptual part of an element
You often combine both:
.button:hover::after {
opacity: 1;
}Common State Pseudo-Classes
:hover
.button:hover {
background: #1d4ed8;
}Code explanation:
- applies when pointing device hovers over the element
- useful for visual affordance on desktop
Do not rely on hover as the only interaction cue (touch devices may not support it consistently).
:active
.button:active {
transform: translateY(1px);
}Short-lived pressed state feedback for click/tap action.
:focus and :focus-visible
.button:focus-visible {
outline: 3px solid #2563eb;
outline-offset: 2px;
}Code explanation:
:focus-visibleusually shows focus ring for keyboard-style navigation- improves accessibility without always showing focus style on mouse click
Prefer :focus-visible for modern UX, with sensible fallback if needed.
Link-Related Pseudo-Classes
a:link {
color: #2563eb;
}
a:visited {
color: #7c3aed;
}
a:hover,
a:focus-visible {
text-decoration-thickness: 2px;
}Use link states to preserve discoverability and interaction clarity.
Form State Pseudo-Classes
input:focus-visible {
border-color: #2563eb;
}
input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
input:invalid {
border-color: #dc2626;
}Code explanation:
:disabledstyles non-interactive controls:invalidreflects browser constraint validation state- combine with labels/help text for clear feedback
Structural Pseudo-Classes
:first-child, :last-child
.list li:first-child {
margin-top: 0;
}
.list li:last-child {
margin-bottom: 0;
}Useful for trimming edge spacing without extra classes.
:nth-child()
.table-row:nth-child(even) {
background: #f8fafc;
}Common for zebra striping and repeated rhythm patterns.
:not()
.button:not(.is-primary) {
background: #e2e8f0;
}Helpful for exclusion logic, but avoid overly complex negation chains.
Useful Modern Selectors
:is()
:is(h1, h2, h3) {
line-height: 1.2;
}Reduces repetitive grouped selector writing.
:where()
:where(article, section) p {
margin-bottom: 1rem;
}:where() contributes zero specificity, which is useful for low-priority base rules.
:has() (powerful, use carefully)
.field:has(input:invalid) {
border-color: #dc2626;
}Lets parent react to child condition. Very expressive, but test browser support in your target matrix.
Pseudo-Elements Overview
Common pseudo-elements:
::before::after::marker::selection::placeholder
Use double-colon modern syntax for pseudo-elements (::before), though some single-colon legacy forms still work.
::before and ::after
.tag::before {
content: "#";
margin-right: 0.25rem;
color: #64748b;
}Code explanation:
- generated content appears before element content
- useful for decorative prefixes/icons
- requires
contentproperty to render
Avoid putting critical semantic content only in generated pseudo-elements.
::marker for List Bullets
ul li::marker {
color: #2563eb;
}Allows list bullet/number styling without replacing list semantics.
::selection
::selection {
background: #bfdbfe;
color: #0f172a;
}Styles user-selected text highlight. Keep contrast readable.
::placeholder
input::placeholder {
color: #94a3b8;
}Placeholder text should remain readable but visually secondary to user-entered content.
Accessibility Guidance
- always provide visible
:focus-visiblefor interactive controls - do not rely only on color changes for state feedback
- keep generated pseudo-element content decorative unless redundancy exists
- ensure hover effects have keyboard equivalents where needed
Warning
Do Not Remove Focus Ring Without Replacement
If you disable default focus visuals, provide a clear custom focus style immediately.
Practical Pattern: Button Interaction States
This gives clear pointer, pressed, and keyboard states with minimal CSS.
Practical Pattern: List Rhythm and Decoration
This pattern adds subtle decoration while preserving semantic list structure.
Common Mistakes
| Mistake | Why it hurts | Better approach |
|---|---|---|
| Hover-only feedback | Touch/keyboard users miss cues | Add focus and active equivalents |
| Removing focus outlines globally | Accessibility failure | Use intentional :focus-visible styles |
| Complex nested pseudo selectors | Hard to debug | Keep selectors readable and scoped |
Critical content in ::before/::after only | Screen-reader/semantics risk | Keep key meaning in real HTML |
Overusing :nth-child for semantic differences | Fragile structure coupling | Use classes when meaning differs |
Debugging Tips
When a pseudo rule does not apply:
- confirm selector match in DevTools
- verify state is active (
:hover,:focus-visible, etc.) - check specificity/order conflicts
- for
::before/::after, ensurecontentexists - test keyboard navigation to validate focus styles
DevTools state toggles are very useful for pseudo-class testing.
Mini Exercise
- Style a link list with hover + focus-visible states
- Add zebra striping with
:nth-child(even) - Add decorative
::beforeicon for tags - Style form fields for
:focus-visible,:invalid, and:disabled - Verify keyboard-only navigation still has clear visual indicators
FAQ
Should I use :focus or :focus-visible?
Use :focus-visible for most interactive UI styling. It usually provides better keyboard-focused behavior.
Is ::before content read by screen readers?
Behavior can vary. Do not rely on generated content for critical meaning or instructions.
Can I style parent elements based on child state?
Yes, with :has() where supported. Test compatibility and keep fallbacks sensible.
Why is my ::before not showing?
Most often because content is missing or empty, or selector does not match.
Is heavy use of pseudo-selectors bad?
Not inherently, but readability and maintainability drop when selectors become overly complex. Keep them intentional and scoped.