The short version: attr() reads a value out of an HTML attribute and hands it to CSS, and the one place it has worked reliably for years is inside the content property on a ::before or ::after pseudo-element. So if you have <span data-tooltip="Saved">, this puts the word "Saved" on screen with no extra markup:
span::after {
content: attr(data-tooltip);
}That is the whole trick, and the rest of this page is the detail around it: when it works, the gotchas that trip people up, the handful of patterns it is genuinely good for, and the accessibility caveat that decides whether you are allowed to use it for a given thing at all.
content needs a pseudo-element
content only does anything on the ::before and ::after pseudo-elements (and on a few list/quote contexts). You cannot set content on a normal <div> and expect text to appear. The pattern is always: target a pseudo-element, give it a content, and it renders as the first or last child of the host element.
.note::before {
content: "Note: ";
font-weight: bold;
}A pseudo-element with no content (or content: none) is not generated at all, so content is also the switch that turns ::before/::after on. For a plain decorative shape with no text, you still need an empty string: content: "". That is the same empty-content idiom the shapes in pure-CSS triangles with the border trick rely on.
One limitation worth knowing up front: replaced elements (<img>, <input>, <br>, <iframe>) do not generate ::before/::after. Their content comes from outside the document, so there is no "before the content" box to fill. If you are reaching for a pseudo-element on an <img>, you need a wrapper element instead.
attr() pulls a value out of the attribute
attr(data-tooltip) reads the data-tooltip attribute off the host element and substitutes its value as a string. The classic gotchas:
- No quotes around the attribute name. It is
attr(data-tooltip), notattr("data-tooltip"). The name is an identifier, not a string. - Concatenate by listing values, not with
+. CSS has no string+operator. You join pieces by putting them side by side:content: "(" attr(href) ")";. - Any attribute works, not just
data-*.attr(href),attr(title),attr(alt)are all fair game. Thedata-*ones are just the tidy place to stash strings that exist only to be displayed.
a[href]::after {
content: " (" attr(href) ")";
color: #666;
font-size: 0.85em;
}The big constraint: string-only, for content
Here is the thing people keep stubbing their toes on. The CSS spec always imagined attr() working everywhere: width: attr(data-width), background: attr(data-color), the lot. For a long time that was a dream. The plain attr(x) form returns a string, and a string is only useful to a property that wants a string, which in practice means content. Writing width: attr(data-w) did nothing, because "120" the string is not 120px the length.
That has started to change. The redesigned typed attr() lets you coerce the attribute into a real CSS type, so the browser parses it as a length, number, color, angle, percentage, and so on:
.bar {
width: attr(data-pct type(<percentage>));
background: attr(data-color type(<color>), gray);
}Support, as of mid-2026: it shipped in Chromium (Chrome 133, early 2025) and is arriving in Firefox 152. Safari has it only behind Technology Preview, with no firm stable date. So typed attr() is real and worth knowing, but it is not something to lean on in production yet without a fallback. You can feature-detect it:
@supports (x: attr(x type(*))) {
/* typed attr() is available here */
}For the foreseeable future, the safe, everywhere-supported use of attr() is still the string form inside content. Everything below assumes that form.
Patterns it is genuinely good for
Tooltips and badges from a data attribute. Stash the tooltip text in data-tooltip, show it on hover from ::after, and you never duplicate the string in CSS. The parent needs position: relative so the absolutely-positioned tooltip anchors to it (a step people forget, and then wonder why the tooltip flies to the corner of the page).
.has-tip {
position: relative;
}
.has-tip:hover::after {
content: attr(data-tooltip);
position: absolute;
bottom: 100%;
left: 0;
background: #222;
color: #fff;
padding: 4px 8px;
white-space: nowrap;
}Print stylesheets that expose link URLs. On paper a link is just blue text, so a print stylesheet can spell out where it points:
@media print {
a[href]::after {
content: " <" attr(href) ">";
}
}Required-field asterisks. Mark a field group and let CSS add the star, instead of hand-typing * into every label:
.field.required label::after {
content: " *";
color: #c00;
}Counters. content is also where CSS counters render, so you can number sections, figures, or steps without touching the markup. attr() and a counter can even share one content line:
ol.steps {
counter-reset: step;
}
ol.steps li::before {
counter-increment: step;
content: "Step " counter(step) ": ";
font-weight: bold;
}The accessibility caveat (this decides everything)
Generated content is not real DOM text, and assistive technology treats it inconsistently. Modern browsers now fold ::before/::after text into the element's accessible name, so a screen reader often does read it, but support varies by browser-and-screen-reader pairing, and it is not something you can rely on across the board. Worse, generated content is not selectable or copyable by the user, ever, and that part is by design.
So the rule is simple: never put information the reader actually needs only in CSS content. Decorative prefixes, hover tooltips, print niceties, counters: fine, the page still makes sense without them. But a price, a status, an error message, or anything the user must read or copy belongs in the HTML, not generated from an attribute.
Where you do want generated content to be spoken sensibly, content accepts an alternative-text string after a slash, which screen readers announce in place of the glyph:
.starred::before {
content: "\2605" / "Starred";
}That / "Starred" is the spoken alternative for the star character. It is supported in current Firefox, Safari, and Chromium, but support is browser-and-screen-reader specific: NVDA, the most-used pairing on Windows, does not announce it, so anything genuinely important still needs a real markup route. For that, an aria-hidden decorative element with separate visible-and-spoken text remains the more bulletproof option. The honest summary: use content and attr() for things that are nice to have, keep essential text in the markup, and you will not get burned. The same "is this actually reachable" instinct applies to the visual-only tricks in truncating text with a CSS ellipsis, where the hidden overflow is gone for a copy-paste user too.
FAQ
See also
- Pure CSS triangles with the border trick: another job for an empty
content: ""pseudo-element, this time for shapes rather than text. - Truncate text with a CSS ellipsis: the visual-only truncation tricks, with the same "what does a copy-paste user actually get" caveat.
- The CSS :has() selector: style a parent based on its attributes or children, which pairs well with attribute-driven generated content.
Sources
Authoritative references this article was fact-checked against.
- content - CSS, MDN Web Docsdeveloper.mozilla.org
- attr() - CSS, MDN Web Docsdeveloper.mozilla.org
- CSS attr() gets an upgrade, Chrome for Developersdeveloper.chrome.com
- CSS to speech: alternative text for CSS-generated content, Sara Soueidansarasoueidan.com
- content: Alternative text after slash, Can I usecaniuse.com





