✨ 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
::beforeand::afterand the requiredcontentproperty - 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-class | Pseudo-element |
|---|---|
| Selects an element by state or position | Selects 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);
}
::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
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
::markerfor 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/::afterto 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
- Give an element
data-tip="Some helpful text"andposition: relative. - On
::after, setcontent: attr(data-tip)and position it above the element, hidden by default. - Reveal it on
:hoverand:focus-visibleby changing opacity.
Part B — Outline
- Take a list of headings and number them automatically with a counter.
- 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/::aftergenerate boxes inside the element and require acontentproperty — usecontent: ""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.