Skip to main content

✨ Pseudo-elements and Generated Content

Selectors so far have targeted elements that exist in your HTML. Pseudo-elements go further: they let you style — and even create — parts of an element that were never written in the markup at all. Icons, drop caps, quotation marks, counters, and decorative flourishes, all with zero extra tags.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Explain how a pseudo-element differs from a pseudo-class, and why the syntax is ::
  • Generate content with ::before and ::after and the required content property
  • Apply typographic pseudo-elements: ::first-letter, ::first-line, ::selection, ::marker, ::placeholder
  • Pull dynamic values with attr() and build automatic numbering with CSS counters
  • Recognize the accessibility limits of generated content

Estimated Time: 35–45 minutes  •  Difficulty: Intermediate

Hands-on: Build a tooltip and an auto-numbered outline using only pseudo-elements.

In This Lesson

What Is a Pseudo-element?

A pseudo-element is a stylable region that the browser conjures for you — the first letter of a paragraph, the marker of a list item, or a brand-new box inserted before or after an element's content. None of these appear in your HTML source, yet CSS treats them as if they were real elements you can paint.

💡 A useful analogy: Pseudo-elements are like the frame, matting, and little brass plaque around a painting. The painting (your real HTML) doesn't change, but you can add decorative structure around and within it — without ever repainting the canvas.

The two you'll use most, ::before and ::after, literally invent a new box as the first or last child of an element. Everything else — ::first-letter, ::selection, ::marker — styles a slice of what's already there.

Pseudo-element vs. Pseudo-class

These look similar and are easy to confuse. The distinction: a pseudo-class selects a whole element that happens to be in some state; a pseudo-element selects a part of an element (or generates a new one).

Pseudo-classPseudo-element
Selects an element by state or positionSelects or creates a part of an element
Single colon :Double colon ::
:hover, :first-child, :checked::before, ::first-letter, ::selection

📖 Why two colons?

CSS2 wrote pseudo-elements with one colon (:before). CSS3 switched to two (::before) to visually separate them from pseudo-classes. Browsers still accept the single-colon form for the four original pseudo-elements, but always write :: in new code — newer pseudo-elements like ::marker only accept the double colon.

::before and ::after

These generate a virtual box inside the element — ::before as its first child, ::after as its last. They are inline by default and require a content property to appear at all.

/* An external-link arrow, no markup needed */
a[target="_blank"]::after {
  content: " \2197";   /* ↗ */
  font-size: 0.8em;
}

/* A decorative box — note content:"" is still required */
.ribbon::before {
  content: "";
  position: absolute;
  inset: 0 0 auto auto;
  width: 2.5rem;
  height: 2.5rem;
  background: var(--primary-color);
  transform: rotate(45deg) translate(1.2rem, -1.2rem);
}
How ::before and ::after wrap real content A parent element box contains three pieces in order: a generated before box, the real HTML content, and a generated after box. .element { } ::before generated real HTML content from your markup ::after generated
Figure 1 — ::before and ::after bookend the real content inside the element. They live within the element's box, so a positioned parent lets you place them anywhere.

⚠️ content is mandatory

Without a content declaration, ::before and ::after simply don't render — even if you set width, height, and a background. For a purely decorative box, use content: "" (an empty string). Forgetting this is the number-one reason a pseudo-element "doesn't show up".

Typographic Pseudo-elements

::first-letter — drop caps

.article p:first-of-type::first-letter {
  font-size: 3em;
  font-family: Georgia, serif;
  float: left;
  line-height: 0.8;
  padding: 0.1em 0.15em 0 0;
  color: var(--primary-color);
}

It selects the first typographic letter of a block, including any leading punctuation — in "(Hello)" it grabs (H. The match recalculates automatically as text reflows.

::first-line — lead-in styling

.intro::first-line {
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.03em;
}

💡 Only some properties apply

::first-letter and ::first-line accept only a limited set of properties — font, color, background, text decoration, and spacing. Layout properties like margin or height are ignored on ::first-line because "the first line" is a fluid, reflow-dependent region, not a fixed box.

::selection, ::marker, ::placeholder

/* Highlight color when the user selects text */
::selection {
  background: var(--primary-color);
  color: #fff;
}

/* Style list bullets/numbers directly — no hacks */
li::marker {
  color: var(--primary-color);
  font-weight: 700;
}

/* Style the greyed-out prompt text in inputs */
input::placeholder {
  color: var(--text-light);
  font-style: italic;
}

::marker replaced years of hacks

Recoloring a list bullet used to mean hiding the native marker and faking one with ::before. Today li::marker { color: … } styles the real marker directly, with full support in every current browser. Prefer it over the old workaround.

The content Property & attr()

The content property accepts more than plain strings. It can pull an attribute's value with attr(), insert an image, or emit language-aware quotation marks:

/* Pull the data-label attribute into a badge */
.badge::after {
  content: attr(data-label);
  margin-left: 0.4em;
  background: #dc2626;
  color: #fff;
  padding: 0.1em 0.4em;
  border-radius: 3px;
  font-size: 0.75em;
}

/* Language-appropriate quotes */
q::before { content: open-quote; }
q::after  { content: close-quote; }

/* An empty string plus a background = a pure decoration */
.divider::before {
  content: "";
  display: block;
  height: 1px;
  background: var(--border-color);
}

⚠️ Generated text is (mostly) invisible to assistive tech — and unselectable

Text you inject via content can't be selected or copied, and screen-reader support is inconsistent. Use it for decoration — icons, quotes, dividers — never for information the user must read or copy. If a screen reader must announce it, put it in real HTML instead.

💡 Give attr() an accessible label

When you surface attr(data-label) as visible content, add a matching aria-label or real text so assistive tech isn't left out. Generated content should enhance, not carry the only copy of meaningful text.

CSS Counters

Counters let CSS number things automatically — sections, steps, nested outlines — without you hand-typing "1.", "2.", "3." into the markup. Three pieces work together: counter-reset starts a counter, counter-increment bumps it, and counter() prints it inside content.

/* Start a counter on the container */
.steps { counter-reset: step; }

/* Increment it once per step and print the value */
.steps > li {
  list-style: none;
  counter-increment: step;
}
.steps > li::before {
  content: "Step " counter(step) ": ";
  font-weight: 700;
  color: var(--primary-color);
}

Counters even nest. For a legal-style outline (1, 1.1, 1.1.1) you reset a child counter on each heading and join levels with counters() (plural):

.outline { counter-reset: h2; }
.outline h2 { counter-reset: h3; counter-increment: h2; }
.outline h2::before { content: counter(h2) ". "; }
.outline h3 { counter-increment: h3; }
.outline h3::before { content: counter(h2) "." counter(h3) " "; }

✅ Why counters beat hard-coded numbers

Insert a new step in the middle and every following number updates itself. There's nothing to renumber by hand and no chance of a "Step 4, Step 4, Step 6" mistake slipping into production.

Accessibility & Best Practices

graph TD A["Need to add something visually?"] --> B{"Is it meaningful content
the user must read or copy?"} B -->|Yes| C["Put it in real HTML"] B -->|No, decorative| D["Use ::before / ::after"] D --> E["content: '' for pure decoration"] D --> F["Icons, dividers, badges, counters"]

✅ Do

  • Use pseudo-elements for decoration, icons, and typographic flourish.
  • Always write the double-colon :: form in new code.
  • Include content: "" for decorative boxes.
  • Prefer ::marker for list bullets over faking them.

⚠️ Don't

  • Don't store essential text only in content — it can't be selected, searched, or reliably read aloud.
  • Don't attach ::before/::after to replaced elements like <img>, <input>, or <br> — they have no place to put generated children.
  • Don't rely on unselectable generated content for phone numbers, codes, or anything users copy.

Hands-on Exercise

🏋️ A CSS-only tooltip and an auto-numbered outline

Objective: Practice generated content, attr(), and counters.

Part A — Tooltip

  1. Give an element data-tip="Some helpful text" and position: relative.
  2. On ::after, set content: attr(data-tip) and position it above the element, hidden by default.
  3. Reveal it on :hover and :focus-visible by changing opacity.

Part B — Outline

  1. Take a list of headings and number them automatically with a counter.
  2. Add a second, nested counter so sub-items read 1.1, 1.2, 2.1, and so on.
💡 Hint

For the tooltip, remember the pseudo-element needs content to exist at all — content: attr(data-tip) satisfies that and supplies the text in one step. Toggle visibility with opacity plus a transition rather than display, so it can animate.

✅ Sample solution (tooltip)
.tip {
  position: relative;
  border-bottom: 1px dotted currentColor;
  cursor: help;
}
.tip::after {
  content: attr(data-tip);
  position: absolute;
  bottom: 125%;
  left: 50%;
  transform: translateX(-50%);
  background: #1e293b;
  color: #fff;
  padding: 0.35rem 0.6rem;
  border-radius: 4px;
  white-space: nowrap;
  font-size: 0.8rem;
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.2s ease;
}
.tip:hover::after,
.tip:focus-visible::after {
  opacity: 1;
}

The tooltip text lives in a real data-tip attribute, so it stays in the DOM even though it's rendered through a pseudo-element. For anything critical, pair it with aria-describedby and a visually-hidden element so screen readers announce it too.

🎯 Quick Quiz

Question 1: Why doesn't this rule render anything: .box::before { width: 20px; height: 20px; background: red; }?

Question 2: Which is the correct, modern way to recolor a list item's bullet?

Question 3: Why should you avoid putting essential, copyable text inside the content property?

Summary & Quiz

🎉 Key Takeaways

  • A pseudo-element styles or creates a part of an element; a pseudo-class selects a whole element by state. Write pseudo-elements with ::.
  • ::before/::after generate boxes inside the element and require a content property — use content: "" for pure decoration.
  • Typographic pseudo-elements (::first-letter, ::first-line, ::selection, ::marker, ::placeholder) style slices of existing content.
  • attr() pulls attribute values and CSS counters number elements automatically.
  • Generated content is decoration only — it can't be selected, copied, or reliably announced.

📚 Further Reading

🚀 What's Next?

You've now mastered selection down to the sub-element level. Next we move from choosing elements to laying them out — the display and positioning properties that control how boxes flow, stack, and sit on the page.

🎉 Excellent work!

Your CSS can now decorate, number, and annotate — all without touching the HTML. On to layout.