The CSS content Property: Generated Content, attr(), counter() and Icons
AI generated
{ }
@
CSS · Pseudo-elements · Icons · Accessibility
The CSS content Property
Generated content, attr() values, counters and icons without extra HTML

content on ::before and ::after is often used only for empty decorative elements, but it can do a lot more: attr() reads HTML attributes directly, counter() numbers lists and chapters automatically, and content: url() embeds image icons, all without extra markup. Knowing these options saves HTML elements, but requires understanding the accessibility limits of generated content precisely.

15 min read ::before · ::after attr() · counter() · content: url()

1. content on ::before and ::after: more than just plain text

The content property is required for a ::before or ::after pseudo-element to render at all, even an empty string content: "" is enough. In practice, though, content usually only gets used for fixed text strings or empty decorative elements, even though the specification allows considerably more data types: HTML attributes, automatic counters, and even external image resources.

The key difference from real HTML content is that generated content never becomes part of the DOM. It appears visually and, in certain cases, gets read out by screen readers, but it cannot be selected with JavaScript, copied, or targeted with document.querySelector. This property makes content ideal for purely presentational additions, but unsuitable for content-relevant information.

2. content with plain text: quoting, escaping and typical uses

The simplest case is a fixed string in quotes, for example typographic quotation marks for citations, separators between breadcrumb items, or an asterisk marking required form fields. Special characters can be inserted directly as Unicode characters in the CSS, or via escape sequences like \2192 for a right arrow, which is the more robust option when the editor has encoding trouble.

It matters that plain text content is regenerated on every render and is not part of the page's translatable strings in any structured sense. On multilingual websites, no content-meaningful text should therefore be inserted via content, only purely decorative or language-neutral symbols such as separators or arrows.


.breadcrumb li:not(:last-child)::after {
  content: " \2192 "; /* right arrow as separator */
  color: #94a3b8;
}

.required-field label::after {
  content: " *";
  color: #dc2626;
}

3. attr(): inserting HTML attributes directly into generated content

The attr() function reads the value of an HTML attribute on the current element and inserts it as text into content. A common use is displaying data attributes, such as tooltip text from data-tooltip, without needing an extra HTML element that duplicates the text.

The big advantage over duplicated markup is that changes to the data attribute via JavaScript immediately affect the visible content, with no need to touch the CSS or the generated content itself. The limit of attr() is that it currently only returns strings, not structured values, and that it does not fit security-critical or purely content-bearing information, because screen reader support for attr() values is inconsistent.


<span class="tooltip-trigger" data-tooltip="Free shipping over 50 euros">
  Shipping costs
</span>

<style>
.tooltip-trigger::after {
  content: attr(data-tooltip);
  display: none;
  position: absolute;
  background: #1e293b;
  color: white;
  padding: 0.5rem;
  border-radius: 0.375rem;
}
.tooltip-trigger:hover::after {
  display: block;
}
</style>

4. counter(): automatic numbering for lists and chapters

CSS counters with counter-reset, counter-increment, and the counter() function inside content produce automatic numbering that stays correct across any change in the order or number of elements, with no single number needing manual upkeep in the HTML. This is especially useful for nested structures such as multi-level FAQ lists or tutorial chapters.

Nested counters can even produce multi-level numbering such as 2.3, by combining an outer and an inner counter. The browser handles the nesting depth automatically, as long as counter-reset is set correctly on the enclosing element and counter-increment on every counted child element.


.chapter-list {
  counter-reset: chapter;
}

.chapter-list .chapter {
  counter-increment: chapter;
}

.chapter-list .chapter::before {
  content: "Chapter " counter(chapter) ": ";
  font-weight: bold;
}

5. Icon fonts versus content-generated SVG and Unicode icons

Icon fonts embed icons as characters of a special font, typically using content with a Unicode code point that in the icon font maps to a glyph symbol instead of a letter. That works reliably visually, but has a structural downside: screen readers often read the underlying Unicode code point as a meaningless letter, or not at all, producing confusing output without additional aria-hidden handling.

As a modern alternative, content: url("icon.svg") embeds a real SVG file as generated content, without the font-file dependency and without the risk of broken character encoding. The downside compared to icon fonts is that SVG icons via content: url() cannot be colored via CSS, while icon font characters can simply be colored with color, since they are technically text.


/* Icon font approach: colorable, but code-point risk */
.icon-cart::before {
  content: "\e901";
  font-family: "IconFont";
  color: #4a1d96; /* colorable */
}

/* SVG approach: more robust, but not colorable via CSS */
.icon-cart-svg::before {
  content: url("/icons/cart.svg");
}

6. content: url() for image icons without an extra img element

content: url() loads an image file, usually SVG or PNG, and renders it as the pseudo-element's generated content, without needing an <img> tag anywhere in the HTML. That is convenient for purely decorative icons that should be consistently controlled through CSS alone, such as a checkmark symbol in front of every list item in a feature list.

It matters that images loaded via content: url() render at their original size and cannot be scaled with width or height on the pseudo-element. For scalable icon sizes, you therefore either need an SVG with an appropriately set intrinsic size, or a switch to background-image with background-size, which offers real size control.


.feature-list li::before {
  content: url("/icons/check-16.svg"); /* fixed 16px size baked into the SVG itself */
  margin-right: 0.5rem;
  vertical-align: middle;
}

7. Accessibility implications: what screen readers do with generated content

Screen reader behavior toward content-generated text is inconsistent across browsers and assistive technologies: some read out plain text content, others ignore it entirely, and others only read it under specific configurations. In practice, this uncertainty means: content must never be the only source of content-important information, such as an error message or a required-field hint that is exclusively displayed via CSS.

For purely decorative generated content, such as separators, icon symbols, or visual counter numbers that carry no independent meaning, this inconsistent read-out is unproblematic, since nobody expects them to be read aloud anyway. It only becomes critical when a developer accidentally offloads real content into content, which then becomes partially invisible to screen reader users despite being clearly visible on screen.

8. Practical recipe: tooltip arrows and counter badges with content

A CSS tooltip with a visible arrow pointing at the trigger element can be built entirely with two pseudo-elements: ::before for the tooltip box itself with content: attr(data-tooltip), and ::after for a small rotated square arrow with empty content: "", made to look like a triangle purely through border tricks.

A cart badge showing item count combines counter() or attr() with absolute positioning: content: attr(data-count) displays the number from a data-count attribute that gets updated via JavaScript or Alpine.js, while the pseudo-element itself only handles the visual circle shape and positioning.


<button class="cart-icon" data-count="3">
  Cart
</button>

<style>
.cart-icon {
  position: relative;
}
.cart-icon::after {
  content: attr(data-count);
  position: absolute;
  top: -6px;
  right: -6px;
  background: #dc2626;
  color: white;
  border-radius: 9999px;
  font-size: 0.7rem;
  padding: 0.1rem 0.4rem;
}
</style>

9. When content is not the right choice

Any content that is necessary for understanding the page belongs in the actual HTML markup, not in generated CSS content. That applies to product names, prices, error messages, form hints, and anything a user should be able to copy, find through the browser's search function, or reliably have read out by a screen reader. Generated content is a tool for presentation, not for semantics.

Method Colorable via CSS Scalable Screen reader behavior
Icon font (content + code point) Yes, via color Yes, via font-size Inconsistent, code point often audible
content: url() with SVG No No, fixed image size Usually ignored, fine for decoration
Inline SVG in HTML Yes, via fill/stroke Yes, via width/height Controllable via aria-hidden/title
background-image No Yes, via background-size Always ignored, purely decorative
attr() for text/numbers Yes, like normal text Yes, via font-size Inconsistent, not fit for important info

Mironsoft

Modern CSS, layout architecture and rendering performance

CSS that stays maintainable instead of breaking with every change?

We review existing stylesheets for specificity chaos and layout thrashing, then build a CSS architecture with cascade layers, custom properties and modern layout primitives that still makes sense after the tenth feature.

CSS Audit

Systematically uncovering specificity issues, cascade conflicts and unused selectors.

Architecture Refactoring

Introducing cascade layers, custom properties and design tokens cleanly.

Performance Tuning

Fixing layout thrashing, expensive selectors and rendering bottlenecks.

10. Summary

The CSS content Property: The Essentials at a Glance

Core idea

content on ::before/::after produces purely visual content not anchored in the DOM, ideal for presentation, not semantics.

attr() and counter()

attr() reads HTML attributes directly, counter() produces automatic, always-correct numbering with no manual upkeep.

Icons

Icon fonts are colorable but carry screen reader risk, SVG via content: url() is more robust but not colorable via CSS.

Accessibility

Content-important information always belongs in real HTML, generated content stays purely decorative.

11. FAQ: The CSS content Property: The Essentials at a Glance

1Does content on ::before/::after become part of the DOM?
No, generated content only appears visually and cannot be selected with JavaScript, copied, or targeted with document.querySelector.
2What exactly does attr() do inside content?
attr() reads the value of an HTML attribute on the current element and inserts it as text into the generated content, for example content: attr(data-tooltip).
3How does automatic numbering with counter() work?
counter-reset resets a counter on an enclosing element, counter-increment increases it on every counted child element, and counter() inside content displays the current value.
4Are icon fonts or content: url() with SVG the better choice?
Icon fonts are easy to color via color, but carry a screen reader risk from the underlying code point. SVG via content: url() is more robust but cannot be colored via CSS.
5Can I color SVG icons loaded via content: url() with CSS?
No. Images loaded via content: url() behave like an embedded image and cannot be colored with fill or color from the pseudo-element.
6Do screen readers read out content-generated text?
Inconsistently. Some browsers and assistive technologies read it out, others ignore it entirely. That is why content must never be the only source of important information.
7Should I display error messages via content?
No. Error messages and other content-important information belong in real HTML markup, so they stay reliably copyable, searchable, and accessible to screen readers.
8Why is an empty content: "" still necessary?
Without a content declaration, the ::before or ::after pseudo-element does not render at all, even if other properties like width or background are set.
9Can content produce multi-level numbering like 2.3?
Yes, by nesting two counters, an outer one for the main number and an inner one for the sub-number, combined in a single content value.
10Is content suitable for multilingual websites?
Only for language-neutral symbols like arrows or separators. Content-meaningful, translatable text should always come from the HTML or a translation system, not from CSS.