CSS pseudo-classes and pseudo-elements
CSS pseudo-classes and pseudo-elements are two types of selectors with one key difference: pseudo-classes target whole elements based on state or position, while pseudo-elements style specific parts of elements or inject content that has no real node in the DOM.
Theory
TL;DR
- Pseudo-classes use a single colon (
:hover); pseudo-elements use a double colon (::before) - Pseudo-classes match real elements based on state or position; pseudo-elements create or target virtual sub-parts
- Think of pseudo-classes as a mood ring on an element (reacts to what is happening); think of pseudo-elements as a sticky note attached to a specific spot
- User interaction and DOM position: pseudo-class. Decoration or content injection: pseudo-element.
::beforeand::afterrequire acontentproperty, even if its value is empty
Quick example
/* Pseudo-class: selects by position */
li:nth-child(odd) { background: #f0f0f0; }
/* Pseudo-element: injects content before each li */
li::before { content: "→ "; }<ul>
<li>Item 1</li> <!-- gray bg + arrow -->
<li>Item 2</li> <!-- no bg + arrow -->
<li>Item 3</li> <!-- gray bg + arrow -->
</ul>The pseudo-class changes the visual state based on DOM position. The pseudo-element adds virtual content that does not exist anywhere in the HTML source.
Key difference
Pseudo-classes respond to conditions outside the element: user interaction, DOM position, or element state (:checked, :disabled). Pseudo-elements create anonymous boxes in the browser's render tree. Those boxes exist in the layout engine but have no corresponding DOM node. That is why you cannot query ::before with document.querySelector - there is nothing in the DOM to query.
When to use
- Hover and focus states on buttons and links:
:hover,:focus - Zebra striping in lists or tables:
:nth-child(odd),:nth-child(2n) - Form state styling:
:checked,:disabled,:required - Decorative icons or counters before or after elements:
::before,::after - Drop caps or styled first lines in articles:
::first-letter,::first-line - Custom text selection color:
::selection
If you find yourself adding an empty <span> just for decoration, that is a sign ::before or ::after will do the job with less markup.
Comparison table
| Aspect | Pseudo-classes | Pseudo-elements |
|---|---|---|
| Notation | Single colon :hover | Double colon ::before |
| Targets | Whole existing elements | Parts or virtual sub-elements |
| DOM presence | Matches real nodes | Generates render-only boxes |
| Common examples | :hover, :nth-child(2n), :not(), :has() | ::before, ::after, ::first-line, ::selection |
| Queryable from JS | Yes | No |
| When to use | State-based or position-based selection | Decorative content, partial element styling |
How the browser handles it
When the style engine resolves selectors, pseudo-classes evaluate dynamic conditions against the computed style tree. :hover matches when a mouseenter event reaches the element. Pseudo-elements like ::before generate anonymous inline boxes in the layout tree. These boxes inherit styles from the parent but have no real HTML node. Changing ::before content from JavaScript is not directly possible, but a CSS custom property works as a bridge between JS and the pseudo-element.
Common mistakes
Single colon for pseudo-elements:
/* Old syntax - avoid */
p:before { content: ""; }
/* Correct */
p::before { content: ""; }Single colon still works in most browsers for backward compat, but it blurs the line between pseudo-class and pseudo-element syntax.
:nth-child counting the wrong siblings:
.item:nth-child(2) { color: red; }<div><span class="item">1</span></div>
<div class="item">2</div> <!-- NOT red: it is the 1st child of its own parent -->:nth-child counts among siblings of the same parent, not among all .item elements in the DOM. When items sit in different containers, the counter resets per container.
Missing content on ::before or ::after:
/* Nothing renders - content is required */
.icon::before { font-family: "Icons"; }
/* Correct */
.icon::before { content: ""; font-family: "Icons"; }Removing :focus outline without a replacement:
/* Breaks keyboard navigation */
button:focus { outline: none; }Remove the outline only if you replace it with something equally visible. WCAG 2.1 AA requires visible focus indicators. A large portion of accessibility bugs I've seen in production codebases trace back to this exact line.
Real-world usage
- Tailwind CSS:
hover:,focus:variant prefixes compile to:hover,:focuspseudo-classes - Bootstrap 5:
::before/::afterfor chevron icons in accordions and dropdowns - GitHub:
:nth-child(odd)for PR list zebra striping,::beforefor label decorations - Material-UI:
:checkedfor switch toggle states,::selectionfor branded text highlight
Follow-up questions
Q: What is the difference between :hover in CSS and mouseenter in JavaScript?
A: :hover bubbles through children, so hovering a nested element also marks the parent as hovered. mouseenter in JS fires only on the direct target with no bubbling.
Q: Can you nest pseudo-elements, like ::before::after?
A: No. The spec does not allow pseudo-elements to have their own pseudo-elements. Use a real child element instead.
Q: What is the specificity of :not(.foo)?
A: :not() itself adds no specificity. Only the argument inside counts. So :not(.foo) has class-level specificity (0,1,0), the same as .foo alone.
Q: How does :has() change the way you write pseudo-classes?
A: :has() is a parent selector supported in Chrome 105+ and Safari 15.4+. section:has(.warning) { border: 2px solid red; } does what used to need JavaScript. It makes pseudo-classes genuinely reactive without any JS at all.
Q (senior): What is the render performance difference between 100 ::before elements and 100 extra <span> nodes?
A: Pseudo-elements create extra boxes in the layout tree, which adds paint and layout cost. Real DOM nodes also carry cost, but browsers are heavily optimized for them. For large lists with ::before, profile in the Chrome DevTools Layout panel. In most cases the difference is negligible, but at thousands of items, real nodes may win.
Examples
Zebra striping with priority icons
.issue { padding: 1rem; border-bottom: 1px solid #eee; }
.issue:nth-child(odd) { background: #f6f8fa; }
.issue.severity-high::before {
content: "🔥 ";
font-size: 1.2em;
}<div class="issue severity-high">Fix login bug</div> <!-- gray + fire icon -->
<div class="issue">Update docs</div> <!-- white, no icon -->
<div class="issue severity-high">Security patch</div> <!-- gray + fire icon -->:nth-child(odd) stripes rows by position. ::before on a specific class adds decorative content only for high-priority items. No extra HTML elements needed for either effect.
Scoped :nth-child in nested lists
/* Wrong: counts globally, breaks when you have multiple lists */
.row:nth-child(2n) { background: #f0f0f0; }
/* Right: scoped per parent container */
.list .row:nth-child(2n) { background: #f0f0f0; }<div class="list">
<div class="row">A</div> <!-- no bg -->
<div class="row">B</div> <!-- gray -->
</div>
<div class="list">
<div class="row">C</div> <!-- gray with scoped version, wrong without it -->
</div>Without the .list scope, :nth-child counts all sibling .row elements across the page. Adding the parent selector gives each list its own independent counter. This is a common source of striping bugs in React lists where components render into separate containers.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.