CSS Quotes — Why Minifiers Break Unicode Escapes
Minifiers silently drop CSS quotes rules with backslash escapes, causing quotation marks to vanish by locale.
20+ years shipping production JavaScript and front-end systems at scale. Everything here is grounded in real deployments.
- CSS quotes property controls the quotation marks inserted by content: open-quote / close-quote
- Use the `
quotes` rule to set custom quote pairs for any language or design - Nested quotes automatically use the second pair, third pair, and so on
- Performance: no runtime overhead — quotes are static content, computed at render time
- Production insight: quote marks disappear or appear incorrectly if `
quotes` not set or inheritance broken - Biggest mistake: assuming `
quotes` is inherited — it's not; each element must explicitly define or inherit via the cascade
Imagine you run a restaurant with locations in Paris, Tokyo, and New York. Each city's menu uses a different style of quotation marks — French guillemets (« »), Japanese corner brackets (「 」), and standard English curly quotes (
Quotation marks in web content are easy to get wrong. Hardcode them as HTML entities and you'll chase broken nesting, mismatched styles, and accessibility issues across every page. The CSS `quotes property solves this cleanly — it tells the browser what characters to use for open-quote and close-quote values. No more manual escaping. No more mixing guillemets and curly quotes on the same site. This is the property you reach for when your design requires French, German, or Japanese quotation conventions without littering the markup with « and »`.
What the `quotes` Property Actually Does
The quotes property defines what characters the browser should insert when it encounters the CSS-generated content values open-quote and close-quote. You've probably used content: open-quote; inside ::before to automatically insert a quotation mark. Without quotes, the browser uses its default — usually the language-appropriate characters based on the element's lang attribute. That's fine for simple English text. But when you need specific typographic style (curly quotes, guillemets, corner brackets) or when you're supporting multiple languages on the same page, defaults won't cut it.
You set it like this: quotes: '“' '”' '‘' '’';. The first two values are the outermost open/close quote; the next two are for first-level nested quotes. You can add as many pairs as you need, though most designs never exceed three nesting levels.
- Level 1 (outermost): uses pair index 0 — open/close
quotes as a list of character pairs, _not_ a single string.quotes: '"' '"' (only one pair), nested quotes will reuse the same pair — never alternate.quotes property is a list of character pairs, consumed level by level.quotes — let the browser default applyquotes: '“' '”' '‘' '’'; explicitly:lang() selectors to switch quotes per languagequotes to any characters you want — no limitsLanguage-Specific Quotation Marks via `:lang()`
Different languages have distinct quotation conventions. French uses guillemets (« »), German uses „low double“ and “high double” („“), Japanese uses corner brackets (「 」). The simplest way to handle this is to define the quotations in CSS rules scoped to the :lang() pseudo-class. This works hand-in-hand with the lang attribute you put on <html> or section elements.
When the browser sees open-quote, it checks the element's language. If a matching quotes rule is found via :lang(en), it uses that. Otherwise, it falls back to the element's own quotes value (if set) or the default. The key is to set :lang(X) selectors for every language your site supports — otherwise your French pages may render English-style quotes.
lang attribute on the element or an ancestor, the browser cannot infer the language. The :lang() selector will not match, and the default (usually browser-locale-based) will apply. Set lang on <html> for the whole page, then override on specific elements when needed.<html> tag has no lang attribute, or it's set programmatically but too late.lang="en" (or your primary language) in the server-rendered HTML.:lang() selectors to switch quotes per language.lang attribute must be present for browsers to match the rule.:lang() rule per language, each with its own quotes list:lang()lang attribute on each piece of content via the API; CSS will pick it upNested Quotes: How the Browser Alternates Pairs
When you have a quotation inside another quotation (e.g., a quote within a blockquote), the browser automatically alternates the quotation marks. It uses the pairs defined in quotes in order: first pair for outermost, second pair for first nesting level, third for second nesting, etc. If there are more nesting levels than pairs, the browser cycles back to the last pair.
This behavior is automatic — you don't need to track nesting level in your CSS. The browser counts how many open-quote calls are active (without matching close-quote) and picks the appropriate pair. That means your CSS can be completely declarative: you just define the pairs, and the browser handles the rest.
For five or more levels of nesting (rare in practice), you should define enough pairs to cover the deepest expected nesting. If you define only one pair, all nesting levels will use the same characters, which looks wrong.
quotes pairs in order.The `open-quote` and `close-quote` Pseudo-Elements
The quotes property is meaningless on its own — it only takes effect when an element uses the content property with the values open-quote or close-quote. These are typically used in ::before and ::after pseudo-elements to surround inline quotes or blockquote decorations.
Most implementations put open-quote on ::before and close-quote on ::after. That's the idiomatic approach. However, you can place them anywhere — even on actual elements (using content: open-quote on an element directly, though that requires the element to be a replaced element or have a certain layout; less common). The key is consistency: the browser keeps a stack of open quotes for each element tree. When you issue close-quote, it closes the most recent unclosed open-quote in that scope.
This stacking mechanism is why you can nest quotes correctly without JavaScript: the browser implicitly tracks the depth.
content property.content: open-quote — the quotes property will supply the right character.open-quote and close-quote are the values you must use in content.quotes handle the symbol selection.Custom Quotes Beyond Standard Typography
The quotes property accepts any Unicode characters, not just traditional quotation marks. You can use emoji, symbols, or even repeated characters to create decorative borders. For example, you could use >> and <<, or custom bracket shapes. The only limitation is that the characters must be representable in the document's encoding (UTF-8 recommended).
This is useful for theming: imagine a fantasy website that uses star symbols (★☆) as quote markers. Or a code documentation site that uses backtick-like characters. The quotes property makes this possible without touching the HTML.
quotes are supported, but they may affect line height and spacing.quotes can use any Unicode character — emoji, arrows, stars.The Production Trap: Why `quotes` Won’t Save Your Translation Files
You've memorized the quotes property syntax. Congratulations. Now go delete it from your i18n pipeline. Relying on CSS for quotation marks creates a brittle coupling between your presentation layer and your content layer that breaks the moment you need to localize quotes dynamically.
The core problem: CSS quotes is a purely visual property. It doesn't touch the DOM. When your backend returns a string like He said, "Hello", and you apply quotes: '«' '»', the browser swaps the ASCII double-quotes for «Hello». That works—until your translator hands you a CDATA block with actual Unicode quotation marks embedded in the source. Now your CSS and the raw content fight each other, producing double-quoted gibberish.
Worse, any JavaScript that reads innerText or processes the string for analytics will see the raw ASCII quotes, not the styled version. You've created a two-faced API: pretty in the browser, ugly in every other context.
Real solution: Handle quotation marks at the template level or in your translation middleware. Let the backend or a build-time transform insert the correct Unicode characters (like “ and ”) based on locale. CSS quotes is fine for static, single-language brochures. For dynamic apps, it’s a liability.
quotes with server-rendered content that already contains quote entities. You'll get double rendering: '„„Hello““'. Instead, strip ASCII quotes in the JS layer before applying locale rules.quotes is a presentational band-aid. For multi-locale apps, handles quotes in your template/render layer, not in your stylesheet.Debugging Nested Quotes: When Your Blockquote Looks Like a Typing Disaster
You've got a blockquote inside a blockquote inside a blockquote. Your client's lawyer is emailing you screenshots of what looks like a prison tattoo: '"‘"'. Calm down. The browser's default quotes property only defines four levels of nesting. After that, it cycles back to the first level. That's why nested quotes degrades into an unreadable mess.
Here's what happens: The HTML spec defines a default quote stack for each language. For English, it's “ ” (level 1), then ‘ ’ (level 2), then back to level 1 for level 3. If you have three levels of <q> or blockquote, level 3 looks identical to level 1. Your reader can't tell who is quoting whom.
Fix: Override the quotes property with a custom stack that provides unique symbols for up to six levels. I use this pattern on documentation-heavy sites: - Level 1: “ ” (double quotes) - Level 2: ‘ ’ (single quotes) - Level 3: « » (guillemets, visually distinct) - Level 4: „ “ (low double, inverted)
Then enforce a lint rule that limits blockquote nesting to 4 levels in your markdown parser. Your designers will thank you. Your support team will stop filing bugs about 'random curly braces.'
quotes stack from a config file. Then you can update locales globally without touching CSS. Also, add a max-nesting-depth to your stylelint config to prevent more than 4 nested blockquotes.Why `quotes` Doesn’t Respect the `content` Property Like You Think
Junior devs think setting quotes in CSS magically injects quotation marks everywhere. It doesn’t. The property only defines the character pairs. You still need the content property with open-quote or close-quote to actually render them. That’s why you can hunt through your stylesheet, see quotes defined, and wonder why nothing shows up.
The real enemy? Someone forgot to add content: open-quote to the ::before pseudo-element. Or worse, they used content: "“" directly, hardcoding the character instead of using the semantic value. That breaks nested alternation and language switching.
Production fix: Always pair quotes with content on ::before and ::after. If you’re using a CMS that strips pseudo-elements, your quotes will vanish silently. Audit your build pipeline for that.
@layer base), your custom quotes will be ignored. Always declare your content in the same specificity level.quotes without content on ::before/::after is dead code. No content = no quotes.The `no-open-quote` Hack That Saves Nested Messes
When you have three levels of nested blockquotes, the browser alternates between the first and second quote pair. That’s fine for English. Real problems start when your design calls for a single quote mark at a specific level, but CSS keeps alternating and gives you the wrong character.
Solution: Use no-open-quote as a content value. It increments the quote nesting counter without rendering a character. This forces the next open-quote to use the correct pair from your list, skipping a level.
Why bother? Because production translations break. German uses „“ but English uses “”. If you’re serving multilingual content, no-open-quote lets you reset mid-nest without touching JavaScript. No runtime cost. No localization bugs.
Test this: apply content: no-open-quote on the deep nested element’s ::before. The browser skips one quote generation. Suddenly your quote marks align with your translation file. Single line fix, no framework overhead.
no-open-quote as a debug tool. When nested quotes look wrong, add it to the deepest ::before and watch the pattern correct itself. Easier than rewriting your quotes property.no-open-quote manipulates the quote counter without rendering. Use it to skip pairs and match language-specific quoting conventions.Formal Definition: What the Spec Actually Says
The CSS quotes property is formally defined in the CSS Generated Content Module Level 3. Its initial value is auto, meaning the browser uses language-specific quotation marks from the user agent stylesheet. When set explicitly, the value is a space-separated list of string pairs: [<string> <string>]+. Each pair defines an opening and closing quote. The first pair is for the outermost quote level, the second for the first nesting, and so on. If you provide fewer pairs than nesting levels, the browser repeats the final pair. The property applies to all elements but only takes effect when content uses open-quote or close-quote values. Common values include none (no quotes generated) and custom pairs like '\201C' '\201D' '\2018' '\2019' for curly English quotes. Failing to match the pair count to your nesting depth produces undefined rendering across browsers.
auto in edge cases. Safari may ignore auto on <q> inside shadow DOM. Always test across engines.quotes property is a list of string pairs; missing pairs repeat the last one, causing unintended quote styles in deep nesting.CSS Styling: Making Quotes Look Intentional
The quotes property alone does not style quotation marks—it only defines their character content. To make quotes visually distinct, use CSS font properties, color, and spacing on the generated content. Target the ::before and ::after pseudo-elements that browsers create for open-quote and close-quote. For example, assign a larger font-size, a distinct color, or font-weight to make opening quotes stand out. You can also add margin-right or margin-left to separate the quote character from the text. Combine with content: open-quote and content: close-quote in custom pseudo-elements for fine-grained control. Avoid using padding on the pseudo-element itself—it shifts the text layout. Instead, apply margin to the quote character. For blockquotes, consider adding a vertical bar or background color via the pseudo-element, independent of the quote marks. This separation of quote definition from quote styling prevents layout regressions when quotes change per locale.
font-size on ::before shifts the quote baseline relative to adjacent inline text. Use vertical-align or line-height adjustments to correct it.quotes property itself, which only defines character content.Chinese quotes disappeared after a CMS upgrade
quotes: '\201C' '\201D' '\2018' '\2019'; was dropped because the minifier saw it as invalid — the actual Unicode escapes were correct but the original author used a wrong backslash pattern that broke after minification.quotes: '“' '”' '‘' '’';`. Then verify the minifier preserves them (upgrade to a CSS-aware minifier that handles Unicode properly).- Never rely on Unicode escapes inside CSS strings — use the real characters directly.
- Always verify `
quotes` render correctly after any CSS pipeline change (minifier, postcss, purgecss). - Add a visual regression test that checks quotation marks are present in every locale.
quotes is inheritable but must be set on a parent. Check if a CSS reset like normalize.css sets quotes: none`.quotes property defines at least two pairs: the first for outermost, second for first nesting level. Add more pairs for deeper nesting.:lang selector overrides the quotes property. Use :lang(fr) { quotes: '«' '»' '‹' '›'; }`.font-family` for proper coverage.DevTools > Computed > filter 'quotes' — confirm property valueCheck stylesheets for `quotes: none` in resets or base stylesquotes: auto; (modern browsers) or explicit pairs to the containerKey takeaways
quotes property is a list of character pairs, consumed level by level for nested quotes.:lang() selectors for multi-lingual sites; ensure the lang attribute is present.<q> or <blockquote> elements work automatically.Common mistakes to avoid
4 patternsForgetting to set `quotes` before using `open-quote`
quotes on the element or a parent before using content: open-quote/close-quote. Use quotes: auto as a quick fallback.Using only one pair of quotes in the `quotes` property
quotes: '“' '”' '‘' '’';. Add a third if deep nesting is expected.Hardcoding quote characters in `::before`/`::after` content
content: open-quote; and content: close-quote; and manage styling via the quotes property.Not setting `lang` attribute on the `<html>` element
:lang() rules never match; quotes fall back to browser defaults.lang="en" (or appropriate) on the <html> tag immediately, and override with lang attribute on containers when content language differs.Interview Questions on This Topic
How does the CSS `quotes` property interact with the `content` property?
quotes property defines character pairs that are inserted when you use content: open-quote and content: close-quote in pseudo-elements (::before, ::after). The browser maintains a stack of open quotes per element tree. When it encounters open-quote, it pushes the current level onto the stack and outputs the corresponding open character from the pair. close-quote pops the stack and outputs the matching close character. This ensures correct nesting without JavaScript.Frequently Asked Questions
20+ years shipping production JavaScript and front-end systems at scale. Everything here is grounded in real deployments.
That's HTML & CSS. Mark it forged?
8 min read · try the examples if you haven't