Type something to search...
CSS Pseudo-Classes and Pseudo-Elements: A Practical Guide

CSS Pseudo-Classes and Pseudo-Elements: A Practical Guide

Pseudo-classes and pseudo-elements are some of the most useful tools in CSS, and also some of the most commonly confused. One uses a single colon, the other uses two. One selects elements based on their state or position, the other lets you style a part of an element that doesn't exist in your HTML at all. Together they let you build hover effects, form validation feedback, zebra-striped tables, decorative flourishes, and even entire parent-aware layouts without adding a single extra class or line of JavaScript.

In this guide, we'll clear up the difference once and for all, then walk through the pseudo-classes and pseudo-elements you'll actually use day to day, with practical, copy-pasteable examples for each.

The Difference in One Sentence

A pseudo-class selects an existing element when it's in a particular state (:hover, :checked, :first-child). A pseudo-element creates or targets a part of an element that isn't a separate node in the DOM (::before, ::first-line, ::placeholder).

/* Pseudo-class: the link, when hovered */
a:hover {
  text-decoration-thickness: 2px;
}

/* Pseudo-element: the first line of a paragraph */
p::first-line {
  font-weight: 600;
}

The double colon for pseudo-elements was introduced in CSS3 to make the distinction visible. Browsers still accept the old single-colon syntax for the original four (:before, :after, :first-line, :first-letter) for backward compatibility, but use :: in new code.

User Action Pseudo-Classes

These respond to what the user is doing.

:hover, :active, and :focus-visible

.button {
  background: #4f46e5;
  color: #ffffff;
  border: 0;
  border-radius: 0.5rem;
  padding: 0.625rem 1.25rem;
  transition:
    background-color 0.15s ease,
    transform 0.1s ease;
}

.button:hover {
  background: #4338ca;
}

.button:active {
  transform: translateY(1px);
}

.button:focus-visible {
  outline: 3px solid #38bdf8;
  outline-offset: 2px;
}

Use :focus-visible rather than :focus for focus rings. The browser shows :focus-visible when it judges that a visible indicator is helpful, such as keyboard navigation, but not when a mouse user clicks a button. Never remove focus outlines without providing an alternative; keyboard users rely on them.

Hover Only on Devices That Can Hover

On touch screens, :hover styles can "stick" after a tap. Wrap hover-only enhancements in a media query:

@media (hover: hover) and (pointer: fine) {
  .card:hover {
    transform: translateY(-4px);
    box-shadow: 0 12px 24px rgb(0 0 0 / 0.1);
  }
}

:focus-within

:focus-within matches an element if it or any descendant has focus. It's perfect for highlighting a whole form group or keeping a dropdown open while the user tabs through it:

.search-field:focus-within {
  border-color: #4f46e5;
  box-shadow: 0 0 0 3px rgb(79 70 229 / 0.2);
}

Structural Pseudo-Classes

These select elements by their position in the document tree.

:first-child, :last-child, and :only-child

.nav-list li:last-child {
  margin-right: 0;
}

.tag-list li:only-child {
  border-radius: 999px;
}

:nth-child() and Its Formulas

:nth-child() accepts a keyword or an An+B formula:

/* Zebra stripes */
.data-table tr:nth-child(even) {
  background: #f9fafb;
}

/* Every third card starts a new row accent */
.card:nth-child(3n + 1) {
  border-left: 4px solid #818cf8;
}

/* The first three items */
.feature:nth-child(-n + 3) {
  font-weight: 600;
}

/* Everything from the fourth item onward */
.feature:nth-child(n + 4) {
  opacity: 0.8;
}

The of S syntax filters which siblings are counted. This stripes only the visible rows, ignoring ones hidden with a class:

.data-table tr:nth-child(even of :not(.is-hidden)) {
  background: #f9fafb;
}

The of S syntax is supported in all current major browsers.

:nth-of-type() and :first-of-type

The -of-type variants count only siblings with the same tag name. That makes a difference in mixed content:

/* The first paragraph in an article, even if an h2 comes before it */
.article p:first-of-type {
  font-size: 1.25rem;
}

:empty

:empty matches elements with no children, not even text. It's handy for hiding empty containers from a CMS:

.alert:empty {
  display: none;
}

Watch out: whitespace counts as a text node in most browsers, so <div class="alert"> </div> is not empty.

Logical Pseudo-Classes

These are the power tools of modern selectors.

:not()

:not() excludes elements that match a selector list:

.nav-list li:not(:last-child) {
  border-right: 1px solid #e5e7eb;
}

input:not([type="checkbox"], [type="radio"]) {
  width: 100%;
}

:is() and :where()

Both match any selector in their list, which shortens repetitive selectors:

/* Instead of .article h1, .article h2, .article h3 ... */
.article :is(h1, h2, h3, h4) {
  line-height: 1.2;
  text-wrap: balance;
}

The difference is specificity. :is() takes the specificity of its most specific argument. :where() always has zero specificity, which makes it ideal for base styles that should be easy to override:

/* Zero specificity: any class selector will win over this */
:where(ul, ol)[role="list"] {
  list-style: none;
  padding: 0;
}

:has(), the Parent Selector

:has() matches an element if any of the selectors passed to it match relative to that element. It finally lets CSS style a parent based on its children:

/* A card that contains an image gets a different layout */
.card:has(img) {
  display: grid;
  grid-template-columns: 8rem 1fr;
  gap: 1rem;
}

/* Highlight a form field whose input is invalid after the user typed */
.field:has(input:user-invalid) {
  --field-border: #dc2626;
}

/* Prevent scrolling when a modal dialog is open */
html:has(dialog[open]) {
  overflow: hidden;
}

:has() can also look at following siblings:

/* An h2 immediately followed by a paragraph gets less bottom margin */
h2:has(+ p) {
  margin-bottom: 0.5rem;
}

:has() is supported in all current major browsers. For older browsers you can wrap enhancements in @supports selector(:has(*)).

Form State Pseudo-Classes

Forms have a rich set of state pseudo-classes, and using them well removes a lot of JavaScript.

<form class="signup">
  <label for="email">Email</label>
  <input id="email" type="email" required placeholder="you@example.com" />
  <span class="hint">We'll never share it</span>

  <label>
    <input type="checkbox" required />
    <span>I agree to the terms</span>
  </label>

  <button type="submit">Create account</button>
</form>
.signup input:required + .hint::after {
  content: " (required)";
}

.signup input:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

.signup input:checked + span {
  font-weight: 600;
}

/* Only show errors after the user has interacted with the field */
.signup input:user-invalid {
  border-color: #dc2626;
}

.signup input:user-valid {
  border-color: #16a34a;
}

/* Style the input while the placeholder is visible (i.e., field is empty) */
.signup input:placeholder-shown {
  background: #f9fafb;
}

The older :invalid pseudo-class matches immediately on page load, so a required empty field shows as an error before the user has typed anything. :user-invalid and :user-valid only match after the user has interacted with the field or tried to submit, which is almost always the behavior you want. They're supported in all current major browsers.

Link and Target Pseudo-Classes

a:any-link {
  color: #4f46e5;
}

a:visited {
  color: #7c3aed;
}

/* The element whose id matches the URL fragment */
.faq-item:target {
  background: #fef9c3;
  scroll-margin-top: 5rem;
}

For privacy reasons, browsers restrict :visited to color-related properties only, so you can't use it to change layout or show content.

Pseudo-Elements

Now for the parts of elements.

::before and ::after

These insert a generated box as the first or last child of an element. They require a content property, even if it's an empty string:

.external-link::after {
  content: "\2197"; /* north-east arrow */
  margin-left: 0.25em;
  font-size: 0.8em;
}

.section-title {
  position: relative;
  padding-bottom: 0.75rem;
}

.section-title::after {
  content: "";
  position: absolute;
  left: 0;
  bottom: 0;
  width: 3rem;
  height: 4px;
  border-radius: 2px;
  background: linear-gradient(90deg, #38bdf8, #818cf8);
}

A few things to know:

  • Replaced elements like img, input, and iframe don't support ::before and ::after in any consistent way. Wrap them if you need decoration.
  • Generated content is read by many screen readers. Don't put essential information in it, and use the alternative text syntax for decorative symbols: content: "\2192" / ""; gives the arrow an empty accessible name.
  • Pseudo-elements are inline by default. Set display or position if you need width and height.

A Practical Pattern: The Clickable Card

A single ::after on the main link can make an entire card clickable, while keeping the HTML clean and accessible:

<article class="post-card">
  <h3 class="post-card__title">
    <a href="/blog/stacking-context">The Stacking Context Demystified</a>
  </h3>
  <p>Why z-index sometimes seems to ignore you.</p>
</article>
.post-card {
  position: relative;
}

.post-card__title a::after {
  content: "";
  position: absolute;
  inset: 0;
}

.post-card:has(a:focus-visible) {
  outline: 3px solid #38bdf8;
  outline-offset: 2px;
}

The pseudo-element stretches over the whole card, so clicking anywhere follows the link. Screen readers still announce just one link with a meaningful name.

::first-letter and ::first-line

Classic typographic touches:

.article > p:first-of-type::first-letter {
  float: left;
  font-size: 3.5em;
  line-height: 0.9;
  margin: 0.05em 0.1em 0 0;
  font-weight: 700;
  color: #4f46e5;
}

For drop caps, the newer initial-letter property handles alignment more precisely, but support is uneven (it works in Chromium and Safari), so keep the float fallback.

::placeholder, ::selection, and ::marker

input::placeholder {
  color: #9ca3af;
  opacity: 1; /* Firefox lowers placeholder opacity by default */
}

::selection {
  background: #c7d2fe;
  color: #1e1b4b;
}

.steps li::marker {
  color: #818cf8;
  font-weight: 700;
}

::marker only accepts a limited set of properties (color, font properties, content, and a few others), so use it for simple bullet styling. For complex custom bullets, use ::before with list-style: none.

::backdrop

::backdrop styles the layer behind top-layer elements like a modal <dialog> or a popover:

dialog::backdrop {
  background: rgb(15 23 42 / 0.6);
  backdrop-filter: blur(4px);
}

Combining Them

Pseudo-classes and pseudo-elements chain together. The pseudo-element must come last:

/* Correct */
.button:hover::after {
  transform: translateX(4px);
}

/* Invalid: pseudo-element must be last */
/* .button::after:hover { } */

A few user-action pseudo-classes, like :hover, are allowed after certain pseudo-elements in newer specs, but for reliable results, always put the pseudo-element at the end.

Specificity Cheat Sheet

  • Pseudo-classes count like a class (0,1,0).
  • Pseudo-elements count like an element (0,0,1).
  • :is(), :not(), and :has() take the specificity of their most specific argument.
  • :where() always contributes zero.

So a:hover has a specificity of (0,1,1), and .card::before is also (0,1,1).

Conclusion

Pseudo-classes let you select elements by state, position, and relationships, and pseudo-elements let you style parts of elements that have no markup of their own. Between :focus-visible, :user-invalid, :nth-child(), :has(), and a well-placed ::after, you can replace a surprising amount of JavaScript and utility markup with a few clear selectors.

Start with the ones that solve real problems in your current project: better focus rings, friendlier form validation, or a clickable card. Once they're second nature, reach for :has() and :where() to write CSS that's both more powerful and easier to override.

Tags :
Share :

Related Posts

A Complete Guide to CSS Container Queries

A Complete Guide to CSS Container Queries

For more than a decade, responsive design meant one thing: media queries. You asked the browser how wide the viewport was and adjusted your layout ac

Continue Reading
A Comprehensive Guide to Installing Next.js

A Comprehensive Guide to Installing Next.js

Next.js has emerged as a powerful framework for building React applications, offering features like server-side rendering, static site generation, an

Continue Reading
Advanced CSS with clamp(), min(), and max(): Simplifying Dynamic Styling

Advanced CSS with clamp(), min(), and max(): Simplifying Dynamic Styling

CSS has evolved significantly, and modern tools like clamp(), min(), and max() are powerful game-changers in dynamic styling. If you’ve struggl

Continue Reading