A CSS pseudo-element generates a box that is not in your HTML source, and ::before and ::after are the two you will meet first. They can draw a divider, hang a badge on a card, pull a label out of an attribute, or number a list without a single extra element. This page shows what they can decorate, why the content property is mandatory, and the accessibility limits that decide what belongs in generated content.
What does a pseudo-element actually create?
::before and ::after create a child box inside the element they belong to: ::before becomes the first child in the box tree and ::after the last. The DOM is not touched. There is no element, no text node, nothing for a script to query and nothing for a validator to complain about, which is exactly why these pseudo-elements are so useful for decoration that has no meaning of its own.
Because it is a real box in the box tree, it can be styled like any other: width, height, background, border, padding, position, and even flex or grid placement. Two details catch people out. Generated boxes are inline by default, so a shape often needs display: block or display: inline-block before it will accept a width. And because the box is part of layout, an empty ::after with display: block inside a flex container becomes a genuine flex item and shows up in the row.
.quote::before {
content: "";
display: block;
width: 48px;
height: 4px;
border-radius: 2px;
background: #2f6fed;
margin-bottom: 1rem;
}Why does ::before need a content property?
For ::before and ::after, the content property decides whether the box exists at all. Without a content declaration, nothing is generated and your rule has no effect whatsoever. content: "" creates an empty box that still takes part in layout, which is how you add a decorative rule, a divider or a deliberate spacer without touching the markup. content: "text" inserts literal text instead. content: none removes a generated box again.
This is why you sometimes see a rule that appears to do nothing: the author styled the pseudo-element beautifully and forgot the content declaration. The fix is one line, and the rule starts working immediately.
<figure class="quote">
<blockquote>
<p>Our support queue halved in a month of using the tool.</p>
</blockquote>
<figcaption>Rosa Alvarez, operations lead</figcaption>
</figure>
<article class="price-card" data-plan="Team">
<h3>Team</h3>
<p class="price">$24</p>
</article>.quote {
padding: 2rem;
background: #f5f8fb;
border-radius: 12px;
}
.quote::before {
content: "\201C";
font-size: 4rem;
line-height: 1;
color: #9db8d2;
display: block;
}
.quote figcaption::after {
content: " — verified customer";
color: #5a6b7c;
}Can you pull real text out of an attribute?
Yes. content: attr(data-plan) inserts the value of an attribute as text, which lets one rule cover every instance of a component. In the price card above, a single ::before rule reads the plan name out of the data-plan attribute, so adding a fourth plan needs no new CSS at all.
There are limits to respect. The inserted value is always plain text: it is never parsed as markup, so you cannot inject a nested element through it, and it is not a DOM node, so it cannot be selected, counted or reliably announced by assistive technology. Treat attribute content as decoration. If the label carries meaning, put it in the HTML where it can be read, indexed and styled.
The same mechanism powers CSS counters. You declare a counter on the parent with counter-reset, increment it on each item with counter-increment, and print it from a pseudo-element with counter(name). The counters() function repeats the whole sequence at each level, which is what nested outlines need.
.price-card::before {
content: attr(data-plan);
display: inline-block;
padding: 0.2rem 0.6rem;
border-radius: 999px;
background: #e6efff;
color: #1c4fa3;
}
.steps {
counter-reset: step;
}
.steps li {
counter-increment: step;
}
.steps li::before {
content: counter(step) ". ";
font-weight: 700;
color: #2f6fed;
}Which other pseudo-elements earn their place?
::first-letter and ::first-line are typographic. ::first-letter applies to block containers only and accepts a limited set of properties: font properties, colour, background, margin, padding, border and text properties. A width or a height on it is simply ignored, so a drop cap needs float or inline-block rather than sizing. ::first-line accepts the text-level properties you would expect: colour, background, font properties, text-transform, letter-spacing and word-spacing.
::marker styles the bullet or number of a list item. You can change its colour, font and size, and even replace it with content: "→ ". ::placeholder styles placeholder text, and only a subset of properties applies to it — colour, font, opacity and similar. Placeholder text is not a label: it vanishes the moment someone types and is often skipped entirely by screen readers, so the real label element must stay in the markup.
::selection sets the highlight colour for selected text, and ::backdrop reaches the backdrop of a dialog opened in the top layer. The first two are small touches that make a page feel considered; neither belongs in a layout decision.
.article p::first-letter {
float: left;
font-size: 3rem;
line-height: 0.85;
padding-right: 0.08em;
color: #1c4fa3;
}
.plan-list li::marker {
color: #2f6fed;
font-weight: 700;
}
.signup input::placeholder {
color: #7c8b9b;
opacity: 1;
}
::selection {
background: #ffe6a7;
color: #17222c;
}What can you not decorate this way?
Replaced elements such as img, video and iframe ignore ::before and ::after, because their content is not generated by the document. Form controls behave the same way: input, textarea and select render their own content, so a pseudo-element on them does nothing. If you need a sale badge on an image, wrap the image in an element and put the badge on the wrapper.
There is a subtler trap with inline elements. A ::before on an inline element adds an inline box to the line, which can change the line box height and shift the surrounding text. Adding display: block or display: inline-block takes the generated box out of the inline flow and gives you predictable results.
/* Does nothing: img is a replaced element */
.thumb::before {
content: "sale";
}
/* Does nothing: input renders its own content */
.field::before {
content: "required";
}
/* Works: the badge belongs on the wrapper, not the image */
.thumb {
position: relative;
display: block;
}
.thumb::after {
content: "sale";
position: absolute;
top: 0;
inset-inline-end: 0;
background: #b3261e;
color: #fff;
padding: 0.15rem 0.5rem;
}Where should generated content stop?
Because generated text is not in the DOM, its exposure to assistive technology is inconsistent. Some screen readers announce it, others ignore it, and the behaviour is not something you should build on. The rule that keeps you safe is simple: if a visitor would lose information when the generated content disappeared, the information belongs in the HTML.
Prices, error messages, labels, counts that matter and anything a screen reader needs all belong in real elements. Quote marks, dividers, decorative badges, list numbers and background shapes are precisely what pseudo-elements are for, and using them there keeps the markup readable for the next person who opens the file.
The rules that keep generated content honest
- Declare
contenton every ::before and ::after, or nothing is generated at all. content: ""still creates a box that takes part in layout; use it for shapes and spacers.- Generated boxes are inline by default, so add
displaybefore you give them a size. attr()inserts plain text only. It is decoration, never a substitute for real content.- Replaced elements and form controls ignore ::before and ::after — put the decoration on a wrapper.
- Never move essential information into generated content; assistive technology support is inconsistent.
Try it yourself: build a testimonial card and a three-item steps list from scratch. Give the card a decorative quote mark with ::before, add a verified-customer note to the caption with ::after, and pull the plan name out of a data attribute on a pricing card. Then number the steps with counter-reset and counter-increment instead of hard-coding "1." into the HTML. Finally, add a sale badge to a product image and watch the ::after on the img do nothing — wrap the image in a figure and place the badge on the wrapper instead.