content: "\00a0"
On non-breaking spaces, hex digits that get swallowed, counters, and the one rule I try to keep about generated content.
Somewhere in every stylesheet I’ve kept for longer than a week there’s a content: "\00a0" and I often wonder how I would explain that to someone new, so I’m going to try to do it here.
The short version is that it’s a non-breaking space, U+00A0, the thing you’d get from in HTML. The longer version is why you’d write it like that instead of just hitting the space bar. That part took me a while.
One of these is not like the others
Say you’ve got a breadcrumb, and you want a slash between the items. So you write the separator into a ::before with a space on either side and it looks fine on your machine at your window width, and then at some narrower width the line wraps directly in front of the slash and now there’s a lone / hanging at the start of a line like it wandered off. A normal space is a legal place to break a line, and the browser is going to take you up on that eventually.
The other half of it is collapsing. White space in generated content goes through the same white space processing as everything else, so a plain space next to other collapsible white space collapses down, and one sitting at the edge of a line box gets dropped entirely. Which is why content: " " on an empty element doesn’t reliably hold the element open. \00a0 is immune to both. It won’t collapse and it won’t break.
.crumb + .crumb::before {
/* the slash stays welded to what precedes it */
content: "\00a0/\00a0";
}
.tag:empty::before {
/* an empty pill that still has a height */
content: "\00a0";
}
And you could paste a real U+00A0 in there instead. Nothing’s stopping you. But then your stylesheet contains a character that is visually indistinguishable from a regular space in every diff and every code editor until the day some well-meaning tool normalizes white space or the file gets served as something other than UTF-8 and the whole thing quietly turns into a  .
Hungry escape characters
Here’s the part nobody tells you: A CSS escape is a \ followed by 1 to 6 hex digits, and it keeps consuming digits until it either hits 6 or hits a character that isn’t one. So the sequence doesn’t end where you think it ends, it ends where the parser runs out of hex.
Which means \a0 for a non-breaking space is a landmine. content: "\a0dog" does not give you a space and then “dog”. It gives you U+A0D0, some CJK ideograph, and then “g.” No error, no warning, just a mystery glyph in your breadcrumb.
Same trap applies to \a, the escape for U+000A, a newline. That one’s useful: give the pseudo-element white-space: pre-wrap and content: "first\a second" puts generated content on two lines. That’s how a lot of CSS-only tooltips work. Just never write \adog, because \ad is a soft hyphen and you’ll spend twenty minutes on it.
The handful I try to keep in my head: \00d7 for a multiplication sign (the thing a close button should be rather than the letter x). \2022 for a bullet. \2192 for a right arrow. \2019 for a curly apostrophe. \2026 for an ellipsis. \2014 for an em dash, which I think is a lazy substitute for a semicolon or some other form of punctuation but I recognize that that’s a me thing and you do you.
No strings attached
I think content gets filed away as “the thing that makes ::before and ::after exist,” and fair enough, since a pseudo-element without a content declaration doesn’t generate at all and content: "" is more important than it looks. But the value accepts a lot more than a quoted string, and some of it is genuinely great.
attr() pulls an attribute into the content, which gives you the one print stylesheet trick everybody should steal: paper can’t be clicked, so put the URL on the page.
@media print {
a[href^="http"]::after {
content: "\00a0(" attr(href) ")";
}
}
/* stacked table rows, labeled from the markup */
td::before {
content: attr(data-label) ":\00a0";
}
Counters are the other one I reach for and then promptly forget about. Pair counter-reset with counter-increment and you get numbering that re-numbers itself when you reorder the HTML, which is a nicer property than it sounds like until the first time you’ve hand-numbered nine headings and then inserted one at the top. counters(section, ".") walks the whole nesting stack, so you get 2.1.4 out of it for free.
And content on a regular element, not a pseudo, replaces the element’s children outright. Which means content: url(logo-inverted.svg) inside a .theme-dark block swaps a logo with no second <img> and no JS. It’s still an <img>, so its alt still applies, which is more than I can say for most image-swapping tricks.
Decoration or duplication
The thing to hold onto is that generated content isn’t text, it just looks like it. It’s not in the DOM. You can’t select it, you can’t copy it, ctrl+f doesn’t find it, and whether a screenreader announces it at all depends on which screenreader and which browser and goodness knows what else. Sometimes it gets read out when you wanted silence which is how a decorative bullet ends up being narrated. Sometimes it’s skipped when it was carrying meaning.
There’s a syntax for the first half of that: a slash and a string sets alternative text, so content: "\2022" / "" marks a decoration as decoration and content: url(bin.svg) / "Delete" gives an icon a name. All three engines have it now, Firefox being last to the party, and I’d still check support before I leaned on it in production.
Generated content can decorate or it can duplicate. It can't be the only place something is said.
But mostly I just try to obey the rule rather than patch around it. Generated content can decorate or it can duplicate. It can’t be the only place something is said. The URL in the print stylesheet is fine because the href is right there in the markup. The data-label on the table cell is fine for the same reason. A required-field asterisk that only exists in a ::before is not fine, and I have definitely made that mistake more than once 🥲
So go pad your escapes to six digits. That’s the actionable part of all this, it costs nothing, and it will save you the 20 minutes I spent on \adog