Chapter 22 Advanced 50 min min read Updated 2026-04-11

Pseudo-classes and Pseudo-elements

Practice Questions →

In This Chapter

What Is It?

What Are Pseudo-classes and Pseudo-elements?

Pseudo-classes and pseudo-elements are two of the most powerful features in CSS. They let you style elements based on state, position, or relationship — and even insert content that does not exist in the HTML.

Pseudo-classes (single colon)

A pseudo-class selects an element based on a state or position. Examples: :hover (when the user points at it), :focus (when it is keyboard-focused), :nth-child(2n) (when it is the even-numbered child), :checked (when a checkbox is ticked).

button:hover { background: purple; }
input:focus { outline: 2px solid cyan; }
li:nth-child(odd) { background: #f1f5f9; }

Pseudo-elements (double colon)

A pseudo-element targets a part of an element — or injects a 'fake' element. Examples: ::before and ::after inject content, ::first-letter targets the first character, ::selection styles selected text, ::placeholder styles the placeholder inside inputs.

.card::before {
  content: 'NEW';
  background: red;
  color: white;
  padding: 4px 8px;
}

Why two colons for pseudo-elements? In CSS3, pseudo-elements got their own double-colon syntax (::before) to distinguish them from pseudo-classes. Browsers still accept the old single-colon form for backwards compatibility, but double colons are the modern standard.

Why Does It Matter?

Why Learn Pseudo-classes and Pseudo-elements?

1. Interactivity Without JavaScript

Hover effects, focus rings, checked states, form validation styling — all handled with CSS. No JavaScript, no event listeners, no bundle size. :hover, :focus, :valid, and :invalid handle the majority of interactive UI in a browser.

2. Less HTML, More Creativity

Need a decorative corner on a card? A tooltip arrow? A quote mark before a blockquote? A ribbon banner? None of these need extra HTML elements. ::before and ::after let you inject the content purely from CSS, keeping your HTML clean and semantic.

3. Pattern-Based Styling

Style every other row of a table, every third item in a grid, or only the last element of a list — without adding class names. :nth-child() is like a tiny query language for DOM position. Anika styled her entire homework table in 3 lines of CSS.

4. Modern Selectors Unlock New Patterns

Newer pseudo-classes like :is(), :where(), and especially :has() (the parent selector) let you write cleaner CSS and style elements based on what they contain. Things that used to require JavaScript now work with a single selector.

5. Accessibility for Free

:focus-visible shows focus rings only for keyboard users, not mouse clicks. :hover does nothing on touch devices, so you never punish mobile users. Semantic pseudo-classes make your site work well for every input device.

Detailed Explanation

A Tour of the Most Useful Pseudo-classes and Pseudo-elements

1. Interaction States

a:link    { color: #7c3aed; }  /* unvisited */
a:visited { color: #db2777; }  /* already visited */
a:hover   { color: #ec4899; text-decoration: underline; }
a:active  { color: #f43f5e; }  /* the instant it is clicked */

button:focus         { outline: 2px solid #06b6d4; }
button:focus-visible { outline: 2px solid #06b6d4; } /* keyboard only */
button:disabled      { opacity: 0.5; cursor: not-allowed; }

:focus-visible is the modern choice: it only shows the focus ring when the user navigates with the keyboard, not when they click with a mouse. Clean and accessible.

2. Form States

input:required { border-left: 3px solid #f59e0b; }
input:valid    { border-color: #10b981; }
input:invalid  { border-color: #ef4444; }
input:disabled { background: #f1f5f9; }
input:checked  { accent-color: #a855f7; }

/* Placeholder-shown: input is empty */
input:placeholder-shown { font-style: italic; color: #94a3b8; }

This lets you show live validation feedback without any JavaScript. A red border appears the moment the user's input becomes invalid.

3. Structural / Positional

li:first-child { font-weight: bold; }
li:last-child  { border-bottom: none; }
li:nth-child(2) { color: red; }
li:nth-child(odd)  { background: #f8fafc; } /* 1st, 3rd, 5th... */
li:nth-child(even) { background: #ffffff; } /* 2nd, 4th, 6th... */
li:nth-child(3n)   { color: purple; }       /* every 3rd */
li:nth-child(3n+1) { color: teal; }         /* 1st, 4th, 7th... */

li:only-child { color: gray; } /* the only child of its parent */
li:empty      { display: none; } /* has no content */

Zebra stripes for tables take 2 lines. Highlighting every third grid item takes 1. This is what designers mean when they say 'pattern-based' styling.

4. Negation and Grouping: :not(), :is(), :where()

/* Not: exclude something */
li:not(.active) { color: gray; }
button:not(:disabled):hover { background: purple; }

/* Is: group selectors without copying */
:is(h1, h2, h3) { font-family: 'Syne', sans-serif; }
/* Equivalent to: h1, h2, h3 { ... } but composable */

/* Where: like :is() but with zero specificity */
:where(article, section) p { line-height: 1.7; }
/* zero specificity means it is easy to override */

5. The Parent Selector: :has()

For years CSS only looked downward. :has() finally lets you select a parent based on its children. This is the biggest new CSS feature in a decade.

/* Style a card that contains an image */
.card:has(img) { padding: 0; }

/* Style a form that has an invalid input */
form:has(:invalid) button { opacity: 0.5; pointer-events: none; }

/* Style a label that has a checked checkbox */
label:has(input:checked) { background: #7c3aed; color: white; }

6. Target and Empty

section:target { background: #fef3c7; } /* the section matching #hash in URL */
div:empty { display: none; } /* element with no content at all */

7. Pseudo-elements: ::before and ::after

These inject a 'fake' element at the start or end of an element's content. They need the content property to render.

blockquote::before { content: '"'; font-size: 64px; color: #a855f7; }
blockquote::after  { content: '"'; font-size: 64px; color: #a855f7; }

.new::after {
  content: 'NEW';
  background: red;
  color: white;
  padding: 2px 8px;
  border-radius: 999px;
  margin-left: 8px;
  font-size: 11px;
}

.tooltip::after {
  content: attr(data-tip); /* read the data-tip attribute */
  position: absolute;
  bottom: -30px; left: 0;
  background: black; color: white;
  padding: 4px 8px; border-radius: 6px;
  opacity: 0; transition: opacity 0.2s;
}
.tooltip:hover::after { opacity: 1; }

8. Text Pseudo-elements

p::first-letter { font-size: 3em; float: left; color: #7c3aed; }
p::first-line   { font-weight: bold; }
::selection     { background: #a855f7; color: white; }
::placeholder   { color: #94a3b8; font-style: italic; }
li::marker      { color: #7c3aed; font-size: 1.2em; }

::selection styles the highlighted text when a user selects anything. ::placeholder styles the hint text inside form inputs. ::marker targets the bullet or number of a list item — you can finally color bullets without extra markup.

9. Creative Patterns

/* Ribbon on a card */
.card::before {
  content: 'SALE';
  position: absolute; top: 12px; right: -28px;
  background: #ef4444; color: white;
  padding: 4px 36px; font-size: 12px;
  transform: rotate(45deg);
}

/* Animated underline on hover */
.nav a { position: relative; }
.nav a::after {
  content: '';
  position: absolute;
  left: 0; bottom: -4px;
  width: 100%; height: 2px;
  background: #a855f7;
  transform: scaleX(0);
  transform-origin: right;
  transition: transform 0.3s;
}
.nav a:hover::after {
  transform: scaleX(1);
  transform-origin: left;
}

No extra HTML, no JavaScript — just a sliding underline that knows which direction to grow from.

Code Examples

Zebra Stripe Table with :nth-child
<!DOCTYPE html>
<html>
<head>
  <style>
    body { font-family: 'Segoe UI', sans-serif; background: #f8fafc; padding: 40px; }
    h1 { color: #7c3aed; margin-bottom: 16px; }
    table { border-collapse: collapse; width: 100%; max-width: 700px; background: white; box-shadow: 0 4px 20px rgba(0,0,0,0.08); border-radius: 12px; overflow: hidden; }
    th { background: #7c3aed; color: white; padding: 14px; text-align: left; }
    td { padding: 12px 14px; color: #334155; }
    tr:nth-child(even) { background: #f1f5f9; }
    tr:hover { background: #ede9fe; }
    tr:last-child td { font-weight: bold; color: #7c3aed; }
  </style>
</head>
<body>
  <h1>Student Scores</h1>
  <table>
    <thead><tr><th>Student</th><th>Chapter</th><th>Score</th></tr></thead>
    <tbody>
      <tr><td>Aarav</td><td>Variables</td><td>94</td></tr>
      <tr><td>Priya</td><td>Variables</td><td>91</td></tr>
      <tr><td>Rohan</td><td>Variables</td><td>88</td></tr>
      <tr><td>Ishita</td><td>Variables</td><td>97</td></tr>
      <tr><td>Vivaan</td><td>Variables</td><td>85</td></tr>
      <tr><td>Anika</td><td>Variables</td><td>99</td></tr>
      <tr><td>Class Average</td><td>---</td><td>92.3</td></tr>
    </tbody>
  </table>
</body>
</html>
tr:nth-child(even) gives every even-numbered row a light background for zebra striping. tr:hover highlights the row the user is pointing at. tr:last-child td styles the summary row. All three effects come from pseudo-classes with zero extra HTML.
A clean table with purple header, zebra-striped rows, a subtle hover highlight, and a bold purple summary row at the bottom.
Tooltip Using ::after and attr()
<!DOCTYPE html>
<html>
<head>
  <style>
    body { font-family: 'Segoe UI', sans-serif; background: #0f172a; color: #f1f5f9; padding: 80px 40px; display: flex; gap: 40px; justify-content: center; }
    .tip {
      position: relative;
      padding: 12px 24px;
      background: #7c3aed;
      color: white;
      border-radius: 10px;
      cursor: pointer;
      font-weight: 600;
    }
    .tip::after {
      content: attr(data-tip);
      position: absolute;
      left: 50%;
      bottom: calc(100% + 10px);
      transform: translateX(-50%);
      background: #0b1120;
      color: #e2e8f0;
      padding: 8px 14px;
      border-radius: 8px;
      font-size: 13px;
      font-weight: 400;
      white-space: nowrap;
      box-shadow: 0 6px 16px rgba(0,0,0,0.4);
      opacity: 0;
      pointer-events: none;
      transition: opacity 0.2s, transform 0.2s;
    }
    .tip::before {
      content: '';
      position: absolute;
      left: 50%;
      bottom: calc(100% + 4px);
      transform: translateX(-50%);
      border: 6px solid transparent;
      border-top-color: #0b1120;
      opacity: 0;
      transition: opacity 0.2s;
    }
    .tip:hover::after,
    .tip:hover::before {
      opacity: 1;
      transform: translateX(-50%) translateY(-4px);
    }
  </style>
</head>
<body>
  <button class="tip" data-tip="Meera's latest project">Hover me</button>
  <button class="tip" data-tip="Click to save changes">Save</button>
  <button class="tip" data-tip="Warning: this deletes everything">Delete</button>
</body>
</html>
Each button has a data-tip attribute. The CSS injects that attribute's text via ::after using content: attr(data-tip). The ::before draws a tiny triangle pointer. Both are hidden with opacity: 0 and revealed on hover. No JavaScript, one CSS rule per button.
Three purple buttons. Hovering any button reveals a dark tooltip above it with a small downward-pointing arrow, showing the text from the data-tip attribute.
Custom Blockquote with ::before and ::after
<!DOCTYPE html>
<html>
<head>
  <style>
    body { font-family: 'Georgia', serif; background: #fafaf9; padding: 60px 40px; color: #0f172a; max-width: 720px; margin: 0 auto; }
    blockquote {
      position: relative;
      background: white;
      padding: 48px 48px 48px 64px;
      border-left: 5px solid #7c3aed;
      border-radius: 0 12px 12px 0;
      font-size: 22px;
      line-height: 1.5;
      color: #334155;
      box-shadow: 0 10px 30px rgba(0,0,0,0.08);
      margin: 40px 0;
    }
    blockquote::before {
      content: open-quote;
      position: absolute;
      top: -20px;
      left: 12px;
      font-size: 120px;
      line-height: 1;
      color: #a855f7;
      font-family: 'Georgia', serif;
    }
    blockquote::after {
      content: '';
      position: absolute;
      right: 20px; bottom: 20px;
      width: 40px; height: 3px;
      background: linear-gradient(90deg, #a855f7, #ec4899);
      border-radius: 2px;
    }
    blockquote cite {
      display: block;
      margin-top: 16px;
      font-size: 14px;
      color: #7c3aed;
      font-style: normal;
      font-weight: 700;
    }
  </style>
</head>
<body>
  <blockquote>
    Learning to code changed how I think about problems. CSS variables and pseudo-elements made me fall in love with design.
    <cite>- Ishita, age 15</cite>
  </blockquote>
  <blockquote>
    You do not need a JavaScript library for nice quotation marks. ::before and ::after handle it beautifully.
    <cite>- Meera, age 13</cite>
  </blockquote>
</body>
</html>
::before uses content: open-quote to render a large purple quote mark floating above the top-left. ::after draws a small decorative gradient bar in the bottom-right. No extra HTML, and the quotes scale with the text automatically.
Two elegant blockquotes. Each has a large decorative purple opening quote mark in the top-left, a purple left border, a subtle gradient accent in the bottom-right, and a styled citation.
Sliding Underline on Nav Links
<!DOCTYPE html>
<html>
<head>
  <style>
    * { box-sizing: border-box; margin: 0; padding: 0; }
    body { font-family: 'Segoe UI', sans-serif; background: #0f172a; color: white; min-height: 100vh; }
    nav { display: flex; gap: 32px; padding: 24px 40px; background: #0b1120; border-bottom: 1px solid #1e293b; }
    nav a {
      position: relative;
      color: #cbd5e1;
      text-decoration: none;
      font-size: 16px;
      padding: 6px 0;
      transition: color 0.2s;
    }
    nav a::after {
      content: '';
      position: absolute;
      left: 0;
      bottom: 0;
      width: 100%;
      height: 2px;
      background: linear-gradient(90deg, #a855f7, #06b6d4);
      transform: scaleX(0);
      transform-origin: right;
      transition: transform 0.3s ease;
    }
    nav a:hover {
      color: white;
    }
    nav a:hover::after {
      transform: scaleX(1);
      transform-origin: left;
    }
    main { padding: 80px 40px; text-align: center; }
    h1 { color: #a855f7; font-size: 40px; }
  </style>
</head>
<body>
  <nav>
    <a href="#">Home</a>
    <a href="#">Projects</a>
    <a href="#">Blog</a>
    <a href="#">Contact</a>
  </nav>
  <main>
    <h1>Vivaan's Portfolio</h1>
  </main>
</body>
</html>
Each nav link has a 2px gradient bar injected via ::after. It starts at scaleX(0) (invisible) with transform-origin: right. On hover it scales to 1 with transform-origin: left, creating a slide-in effect. When the mouse leaves, it retracts back to the right. Pure CSS, no JavaScript.
A navigation bar with 4 links. Hovering any link reveals a gradient bar that slides in from the left. Moving away slides it back out to the right.
Form Validation Styling with :valid and :invalid
<!DOCTYPE html>
<html>
<head>
  <style>
    body { font-family: 'Segoe UI', sans-serif; background: #f8fafc; padding: 40px; display: flex; justify-content: center; }
    .form { background: white; padding: 32px; border-radius: 16px; max-width: 400px; width: 100%; box-shadow: 0 10px 30px rgba(0,0,0,0.08); }
    h2 { color: #0f172a; margin-bottom: 24px; }
    label { display: block; margin-bottom: 16px; font-size: 13px; color: #475569; }
    input {
      display: block;
      width: 100%;
      padding: 12px;
      margin-top: 6px;
      border: 2px solid #e2e8f0;
      border-radius: 8px;
      font-size: 15px;
      outline: none;
      transition: border-color 0.2s;
    }
    input:focus { border-color: #7c3aed; }
    input:placeholder-shown { font-style: italic; }
    input:not(:placeholder-shown):valid { border-color: #10b981; }
    input:not(:placeholder-shown):invalid { border-color: #ef4444; }
    input:required + .req::before { content: ' *'; color: #ef4444; }
    button {
      width: 100%;
      padding: 12px;
      background: #7c3aed;
      color: white;
      border: none;
      border-radius: 8px;
      font-size: 16px;
      font-weight: 600;
      cursor: pointer;
      margin-top: 8px;
    }
    button:disabled { opacity: 0.4; cursor: not-allowed; }
    form:has(input:invalid) button { opacity: 0.4; cursor: not-allowed; }
  </style>
</head>
<body>
  <form class="form">
    <h2>Sign Up</h2>
    <label>Name<input type="text" placeholder="Your name" minlength="2" required></label>
    <label>Email<input type="email" placeholder="you@example.com" required></label>
    <label>Age<input type="number" placeholder="10-18" min="10" max="18" required></label>
    <button type="submit">Create Account</button>
  </form>
</body>
</html>
Inputs get a green border when valid and a red border when invalid — but only once the user has started typing (:not(:placeholder-shown)). The form:has(input:invalid) button rule disables the submit button whenever any input is invalid. All validation styling is pure CSS — no JavaScript.
A clean signup form. As you type, fields turn green when valid and red when invalid. The submit button is disabled until every field is valid.
Ribbon and Badge Effects with ::before
<!DOCTYPE html>
<html>
<head>
  <style>
    body { font-family: 'Segoe UI', sans-serif; background: #f8fafc; padding: 40px; display: grid; gap: 24px; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); max-width: 1000px; margin: 0 auto; }
    .product {
      position: relative;
      background: white;
      border-radius: 16px;
      padding: 24px;
      box-shadow: 0 6px 20px rgba(0,0,0,0.08);
      overflow: hidden;
    }
    .product h3 { color: #0f172a; font-size: 20px; margin-bottom: 8px; }
    .product p { color: #64748b; font-size: 14px; line-height: 1.6; }
    .product .price { display: block; margin-top: 16px; font-size: 24px; font-weight: 700; color: #7c3aed; }

    /* Ribbon on the top-right corner */
    .product.sale::before {
      content: 'SALE';
      position: absolute;
      top: 18px; right: -40px;
      background: #ef4444;
      color: white;
      padding: 6px 46px;
      font-size: 12px;
      font-weight: 800;
      letter-spacing: 1px;
      transform: rotate(45deg);
      box-shadow: 0 4px 10px rgba(0,0,0,0.2);
    }

    /* NEW badge */
    .product.new::after {
      content: 'NEW';
      position: absolute;
      top: 16px; left: 16px;
      background: linear-gradient(135deg, #a855f7, #ec4899);
      color: white;
      padding: 4px 10px;
      border-radius: 999px;
      font-size: 11px;
      font-weight: 700;
      letter-spacing: 1px;
    }
  </style>
</head>
<body>
  <div class="product sale">
    <h3>Ishita's HTML Book</h3>
    <p>Learn HTML and CSS from scratch with 50 projects.</p>
    <span class="price">Rs 499</span>
  </div>
  <div class="product new">
    <h3>Aarav's CSS Guide</h3>
    <p>Master CSS variables, Grid, and animations.</p>
    <span class="price">Rs 399</span>
  </div>
  <div class="product sale new">
    <h3>Kavya's JS Bundle</h3>
    <p>JavaScript basics to advanced in one bundle.</p>
    <span class="price">Rs 899</span>
  </div>
</body>
</html>
.product.sale::before injects a rotated red ribbon in the corner. .product.new::after injects a pill-shaped gradient badge in the top-left. The third card uses both classes and shows both decorations at once. No extra markup — just pseudo-elements.
Three product cards. The first has a diagonal red SALE ribbon in the top-right corner. The second has a purple-pink NEW badge in the top-left. The third has both decorations at the same time.

Common Mistakes

Forgetting the content Property on ::before and ::after

.card::before {
  background: red;
  width: 20px;
  height: 20px;
  /* No content property - nothing renders! */
}
The pseudo-element does not appear at all. Without <code>content</code>, <code>::before</code> and <code>::after</code> are not generated by the browser.
.card::before {
  content: '';  /* Even an empty string works */
  background: red;
  width: 20px;
  height: 20px;
  display: block;
}
The content property is required for ::before and ::after. An empty string content: '' is fine when you only want a decorative shape. Without content, the browser simply does not render the pseudo-element.

Misreading :nth-child() Formulas

/* Wanted every 3rd row, starting from the first */
tr:nth-child(3n) {
  background: #f1f5f9;
}
/* This selects the 3rd, 6th, 9th... - NOT the 1st, 4th, 7th! */
The pattern starts at row 3 instead of row 1. <code>3n</code> means 3, 6, 9, 12 (n starting at 1 internally, or 0 giving 0 which does not exist).
tr:nth-child(3n+1) {
  background: #f1f5f9;
  /* Now selects 1st, 4th, 7th... */
}
The formula is an+b where n goes 0, 1, 2, 3... and the result must be a positive integer. 3n+1 gives 1, 4, 7, 10. 3n gives 3, 6, 9. Sketch a few values to check your formula.

Single Colon vs Double Colon Confusion

.card:before {
  content: 'NEW';
}
/* This works but is the old syntax */
It still works in most browsers for backwards compatibility, but it mixes up the meaning of pseudo-classes (single colon) and pseudo-elements (double colon).
.card::before {
  content: 'NEW';
}
/* Modern CSS3 syntax - use this */
Pseudo-classes use one colon (:hover, :first-child). Pseudo-elements use two colons (::before, ::after, ::first-letter). The double colon was introduced in CSS3 to distinguish them. Always use two colons for pseudo-elements in new code.

Using :hover on Interactive States Alone (No Keyboard Support)

button:hover {
  background: purple;
  outline: 2px solid cyan;
}
/* Keyboard users see no feedback! */
Users who navigate with the keyboard (Tab key) get no visual feedback because only <code>:hover</code> is styled, not <code>:focus</code>.
button:hover,
button:focus-visible {
  background: purple;
  outline: 2px solid cyan;
}
Always pair :hover with :focus-visible (or :focus) so keyboard users see the same affordance as mouse users. :focus-visible is preferred because it only shows the ring for keyboard focus, not for mouse clicks.

Summary

  • Pseudo-classes (single colon, like <code>:hover</code>, <code>:focus</code>, <code>:nth-child</code>) select elements based on state or position. Pseudo-elements (double colon, like <code>::before</code>, <code>::after</code>, <code>::first-letter</code>) target parts of an element or inject new content.
  • <code>:hover</code>, <code>:focus</code>, <code>:focus-visible</code>, <code>:active</code>, <code>:disabled</code>, and <code>:checked</code> handle the majority of interactive UI states without JavaScript. Always pair <code>:hover</code> with <code>:focus-visible</code> for accessibility.
  • Form validation styling is free: <code>:valid</code>, <code>:invalid</code>, <code>:required</code>, and <code>:placeholder-shown</code> let you show live feedback as the user types. Combine <code>:not(:placeholder-shown)</code> with <code>:invalid</code> to only show red after the user has started typing.
  • <code>:nth-child(an+b)</code> is a tiny query language: <code>odd</code>, <code>even</code>, <code>2n</code>, <code>3n+1</code>, etc. Use it for zebra stripes, grid patterns, and highlighting specific items without extra classes.
  • <code>:not()</code> excludes, <code>:is()</code> groups selectors without rewriting them, and <code>:where()</code> is like <code>:is()</code> but with zero specificity (useful for base styles that are easy to override).
  • <code>:has()</code> is the parent selector — finally. Style an element based on what it contains: <code>form:has(:invalid) button</code>, <code>.card:has(img)</code>, <code>label:has(input:checked)</code>. This used to require JavaScript.
  • <code>::before</code> and <code>::after</code> require a <code>content</code> property to render. Even <code>content: ''</code> is enough for a pure decoration. They can read attributes with <code>content: attr(data-tip)</code> — perfect for tooltips.
  • <code>::first-letter</code> and <code>::first-line</code> style the first character or line of a paragraph (drop caps, magazine styling). <code>::selection</code> styles highlighted text. <code>::placeholder</code> styles form placeholders. <code>::marker</code> styles list bullets or numbers.
  • Creative patterns are mostly pseudo-elements: ribbons, badges, tooltips, sliding underlines, quote marks, custom bullets, decorative lines. All without adding a single extra HTML element.

Ready to Practice?

Test your understanding with 50+ practice questions on this topic.

Go to Practice Questions

Want to learn web development with a live mentor?

Explore our Frontend Masterclass