Styling Lists, Links, and Blockquotes: CSS ::marker, text-decoration, and content Quotation Marks
Style lists with ::marker, blockquotes with the quotes property, and links with text-decoration-thickness—modern CSS for semantic HTML elements with fallbacks.
Developers arrive trying to tell the UN observances from the marketing. They assume styling a list marker means hacking a background image onto the li or wrapping the bullet in a span. The truth is that CSS has a dedicated pseudo-element for markers, a set of text-decoration longhands that give you control over underline thickness and offset, and a quotes property that swaps quotation marks based on the page's language. You do not need JavaScript to number a list with custom counters. You do not need a single image file for a bullet that scales with the font size. This guide covers lists, links, and blockquotes through their modern CSS, with runnable samples and the failure cases that trip up even experienced developers.
CSS ::marker Custom List Bullet
What The Pseudo-Element Allows
The ::marker pseudo-element lets you style the marker box of a list item directly. It works on any element with display: list-item, which includes li, summary, and anything you force into that display mode. The critical limitation is the allowed-properties list: color, content, font-*, animation-*, transition-*, direction, text-combine-upright, unicode-bidi, and white-space. You cannot set background, margin, padding, or display inside ::marker. Those declarations are silently ignored. The marker box has a fixed display: marker that cannot be altered.
To get a custom bullet that inherits the list item's font, use content with a string or a counter. The common mistake is applying list-style-type to a parent ul or ol whose li children have display set to something other than list-item. The marker disappears entirely, but the indentation space remains. That leftover space confuses layout. Another mistake is assuming ::marker can take background-image. It cannot. If you need a bullet image with sizing control, use a ::before pseudo-element on the li instead, setting display: list-item on that pseudo-element if you want counter behavior.
Custom Counter With A Checkmark
Here is a runnable sample that uses counter-increment and @counter-style to create a numbered list with a custom marker color and a checkmark symbol instead of a digit. The font stack includes a fallback stack and font-display: swap to avoid invisible text while loading.
@counter-style check-circle {
system: cyclic;
symbols: "✓";
suffix: " ";
fallback: decimal;
}
ol.custom {
list-style-type: check-circle;
counter-reset: section;
font-family: "IBM Plex Sans", "Segoe UI", "Helvetica Neue", Arial, sans-serif;
font-display: swap;
}
ol.custom li {
counter-increment: section;
font-family: inherit;
}
ol.custom li::marker {
color: #0a7d4c;
font-weight: 700;
content: "✓ " counter(section) " ";
white-space: pre;
}
This sample's content inside ::marker overrides the list-style-type for each item, showing that you can mix numeric counters with decorative symbols. If you import a webfont for the marker glyph, state its file size and use font-display: swap to prevent shift.
A second common mistake: defining @counter-style with the symbolic system when you meant cyclic. The system descriptor defaults to symbolic, which repeats the symbols until the count is exhausted, not cycling them. Always set system explicitly. For the additive system, you must use additive-symbols, not symbols. Using symbols there produces an invalid rule that is dropped.
text-decoration Underline Offset Color
Longhands That Do The Work
Underlines on links are the most common text-decoration, but the shorthand often hides useful longhands. The property order is text-decoration-line, text-decoration-style, text-decoration-color, and text-decoration-thickness. The initial value is none currentColor solid auto. If you set only text-decoration-thickness without text-decoration-line, you get nothing visible. The property is inert alone.
text-underline-offset controls the distance the line sits below the text baseline. Its initial value is auto, which lets the browser decide based on the font metrics. A length value like 0.15em gives precise control. A percentage value is relative to the font's underline thickness, not the font size. Another frequent confusion. Negative offsets push the line upward, into the glyphs, which rarely looks good. They do not move the underline above the text.
text-decoration-skip-ink defaults to auto, which makes the underline skip descenders (letters like g, j, p, q, y). Setting it to none forces the line to pass straight through those descenders, which can reduce legibility. Setting it to all makes the underline skip the entire glyph box, creating gaps even where no descender exists. This property is distinct from the older text-decoration-skip, which is not widely supported. Do not confuse the two.
Accessible Links And The Visited Trap
For accessible links, the underline is not optional decoration. Removing it with text-decoration: none and relying on color alone fails WCAG SC 1.4.1 (Use of Color). If you remove the underline, add another non-color cue like a background highlight or a bottom border with sufficient thickness. The :visited pseudo-class only allows a limited set of properties: color, background-color, border-color, outline-color, column-rule-color, fill, and stroke. This restriction prevents history sniffing. Browsers lie about visited styles in getComputedStyle(), so never test visited state with JavaScript.
Here is an accessible link sample with a visible underline, an offset, and a focus-visible ring that uses outline-offset to create a gap between the focus ring and the text.
a.accessible-link {
color: #0b5d9e;
text-decoration: underline;
text-decoration-color: #0b5d9e;
text-decoration-thickness: 2px;
text-underline-offset: 0.2em;
text-decoration-skip-ink: auto;
font-family: "Source Sans 3", "Helvetica Neue", Arial, sans-serif;
font-display: swap;
}
a.accessible-link:visited {
color: #5a3d8b;
border-color: #5a3d8b;
}
a.accessible-link:focus-visible {
outline: 3px solid #f0a500;
outline-offset: 4px;
border-radius: 2px;
}
a.accessible-link:hover {
text-decoration-thickness: 3px;
}
If you use a webfont, state its woff2 file size and preload the font to reduce layout shift. The :focus-visible selector only shows the outline when the user navigates by keyboard, which is the correct behavior for WCAG 2.4.7 (Focus Visible).
blockquote CSS content Quotation Marks
Language-Aware Quotes Without Hardcoding
Blockquotes often get hand-typed quotation marks that do not match the document's language or locale. The quotes CSS property and the content property with the open-quote and close-quote keywords solve this. Set quotes: "“" "”" "‘" "’" for English, or use the appropriate pairs for German, French, or Spanish. The browser inserts the correct glyphs based on the nesting level. A quote inside a quote gets the secondary pair automatically.
The quotes property takes pairs of strings: the opening quote and the closing quote for the first level, then a second pair for nested quotes, and so on. If you omit the property, the browser uses the language-specific defaults from the lang attribute on the html element. That means you often do not need to set quotes at all for a single-language document. The mistake is hardcoding curly quotes in the HTML content. That breaks when the page is translated or when the language changes.
For a blockquote with a cite attribute, you can display the source using a pseudo-element. The cite attribute is not visible by default, but you can add a content: attr(cite) rule to show it. That attribute is also what screen readers announce. It serves both accessibility and visual design.
A Complete Blockquote Sample
Here is a blockquote sample that uses the quotes property for English, with content inserting the marks, and a citation styled via ::after.
blockquote.quoted {
quotes: "“" "”" "‘" "’";
font-family: "Georgia", "Iowan Old Style", "Palatino Linotype", serif;
font-display: swap;
margin: 0;
padding: 0.5rem 1rem;
border-left: 4px solid #ccc;
text-wrap: pretty;
hanging-punctuation: first;
}
blockquote.quoted::before {
content: open-quote;
font-size: 2em;
line-height: 0;
vertical-align: -0.4em;
}
blockquote.quoted::after {
content: close-quote;
font-size: 2em;
line-height: 0;
vertical-align: -0.4em;
}
blockquote.quoted cite {
display: block;
font-style: normal;
font-size: 0.85em;
margin-top: 0.5rem;
}
blockquote.quoted cite::before {
content: ", ";
}
The hanging-punctuation: first property makes the opening quote hang outside the paragraph's text edge, which improves visual alignment. It is not supported in all browsers, so it degrades gracefully. The text-wrap: pretty property avoids widows and ragged edges in multi-line blockquotes. It is a browser-native alternative to JavaScript hyphenation polyfills. Both are progressive enhancements. If unsupported, the blockquote still renders with standard punctuation and wrapping.
A failure case: when you use a custom font for the quotation marks that has not loaded, the browser shows a fallback glyph. To avoid a flash of invisible text, specify a fallback stack that includes a widely available serif or sans-serif with the quotation glyphs, and state the loading cost. If the custom font is 40kB woff2, preload it and use font-display: swap so the fallback shows immediately and swaps when ready.
accessible link styling focus-visible
Why Focus-Visible Beats Focus
Accessible link styling is not about making links pretty. It is about ensuring that keyboard users can see where they are. The :focus-visible pseudo-class applies a focus indicator only when the user is navigating by keyboard, not when they click with a mouse. This prevents the outline from appearing on every mouse click, which many users find visually noisy. The companion property outline-offset creates a gap between the outline and the element. That gap improves visibility for users with low vision because the ring no longer touches the text.
The common mistake is styling :focus rather than :focus-visible. Using :focus alone shows the outline on every interaction, including mouse clicks. That can be acceptable but often triggers the opposite problem: developers remove the outline entirely on :focus to avoid the ring, then fail to add any alternative. Removing the outline without a replacement fails WCAG 2.4.7 (Focus Visible). The correct pattern is to keep a visible indicator for keyboard focus. :focus-visible gives you that without affecting mouse users.
The :visited pseudo-class has privacy restrictions that limit which properties you can change. You cannot read the computed style of a visited link from JavaScript. Browsers return the unvisited values to prevent history sniffing. You also cannot set properties like font-size or text-decoration on :visited. Only the color-related properties listed earlier work. That means your visited style must rely solely on color differences. The WCAG contrast ratio requirement applies to both visited and unvisited states.
Sample With Offset And Outline
Here is a sample that combines :focus-visible with a visible underline offset and a high-contrast outline. The underline uses text-underline-offset so the line does not collide with descenders. The outline uses outline-offset to separate the ring from the glyphs.
a.nav-link {
color: #1a5276;
text-decoration: underline;
text-decoration-thickness: 2px;
text-underline-offset: 0.15em;
text-decoration-skip-ink: auto;
font-family: "Open Sans", "Segoe UI", sans-serif;
font-display: swap;
}
a.nav-link:focus-visible {
outline: 3px solid #e67e22;
outline-offset: 4px;
border-radius: 2px;
background-color: #fdf2e9;
}
a.nav-link:visited {
color: #6c3483;
}
a.nav-link:hover {
text-decoration-thickness: 3px;
background-color: #eaf2f8;
}
If the underline offset is set too large, the line may appear detached from the text. That can confuse users. Test with a few font sizes. The auto value for text-underline-offset is the safest default. Only change it when the default interferes with descenders or diacritics.
text-decoration-skip-ink and Underlines That Breathe
How Skip-Ink Protects Descenders
text-decoration-skip-ink controls whether the underline breaks where it crosses descenders. The initial value auto breaks the line at the descender's intersection, which keeps the glyphs readable. Setting none makes the underline pass straight through the descender. That can clutter the text. The all value skips the entire glyph box, creating a gap even where there is no descender. Useful for certain display fonts but rarely for body text.
This property became Baseline High in March 2022. It is widely available. The older text-decoration-skip property is not a reliable substitute. It has never reached broad support. If you need to support older browsers, the fallback is to use text-decoration-skip-ink: auto, which is the default anyway. You often do not need to write the property at all.
A common mistake is combining text-decoration-skip-ink: none with a thick underline. That can make descenders nearly illegible. A 3px underline through the descender of a "g" in a 16px font reduces the counter space significantly. If you want a continuous underline for design reasons, increase the text-underline-offset so the line sits below the descenders entirely. Set the offset to at least the font size divided by 4, or use a value like 0.15em plus the descender depth.
Browser support for text-decoration-skip-ink is strong across Chrome, Firefox, Safari, and Edge. All supporting versions are well past their initial release. The property is safe for production. The auto value works in every browser that supports the property. There is no need for a fallback beyond the default. Check caniuse for the latest support data.
list-style-position and the Inside/Outside Trap
list-style-position determines whether the marker sits inside the list item's content box (inside) or in the margin area outside it (outside). The initial value is outside. That is the default browsers use. The common mistake with inside is expecting that multi-line text will align with the first character after the marker. Instead, the text wraps to the marker's box edge, which is the marker's right edge, not the text start. The second line starts under the marker, not under the text.
To fix the alignment with inside, you need to set a left padding on the li equal to the marker width plus a gap. But the marker width varies with the content. A better approach: keep outside and adjust the margin or padding of the ul to control the indentation. The outside marker sits in the margin area. It can be clipped by overflow: hidden on a parent container. If you see markers disappearing when you set overflow: hidden on the parent, that is the cause.
Here is a comparison in a runnable sample:
ul.inside-example {
list-style-position: inside;
list-style-type: disc;
padding-left: 1rem;
}
ul.outside-example {
list-style-position: outside;
list-style-type: disc;
margin-left: 1rem;
padding-left: 0;
}
li {
font-family: "Aptos", "Segoe UI", sans-serif;
font-display: swap;
}
The inside-example will show text wrapping under the bullet, not aligned with the text after the bullet. The outside-example will align correctly because the marker is in the margin. To test, wrap the ul in a div with overflow: hidden and observe the outside markers getting clipped. Then add a left margin to the div to compensate.
list-style-type and the Counter Fallback
When Flexbox Kills Your Markers
list-style-type accepts a counter style name, a string, or none. The initial value is disc, the round bullet in most browsers. You can use a string like "→" as a marker. The string is fixed and does not scale with font-size adjustments unless you set it in ::marker with an em-based font-size. The more powerful option is @counter-style, which lets you define a custom numbering system with a fallback descriptor that defaults to decimal.
The common mistake is applying list-style-type to a ul or ol whose children have display: flex or display: grid. Those display values remove the list-item box. The marker disappears. The fix: set display: list-item on the li elements. But that conflicts with flex layout. Instead, use a ::before pseudo-element on the li to create a marker-like box, and set list-style-type: none on the parent.
Step Counter Without List-Style-Type
Here is a sample using a custom counter with a string marker:
.steps {
list-style-type: none;
counter-reset: step;
}
.steps li {
counter-increment: step;
position: relative;
padding-left: 2em;
}
.steps li::before {
content: "Step " counter(step) ": ";
font-weight: bold;
color: #2c3e50;
position: absolute;
left: 0;
}
The counter-reset on the parent initializes the counter. counter-increment on each li increases it. The ::before pseudo-element pulls the counter text into the content. This approach works even when list-style-type is none, because the pseudo-element is not a marker box. For accessibility, the list semantics remain intact. The ul and li elements are still present. Setting list-style-type: none does not remove the list role from the accessibility tree.
Style Queries and the Constraint-Solving Side of CSS
State-Based Theming Without Classes
Modern CSS includes style queries, which respond to the computed value of a custom property on a container. This is different from size container queries, which respond to the container's dimensions. Style queries allow you to write component-variant logic without JavaScript. A button changes its border color when a parent sets --theme: danger. The syntax is @container style(--theme: danger) { ... }.
The distinction matters. Size queries are often used for responsive layout. Style queries are for state-based theming. A common failure: trying to use a size query to change the value of a custom property. Size queries cannot set custom properties. They only conditionally apply styles. The custom property must be set on an ancestor. The style query checks its computed value.
For this page's subject, style queries are not directly needed for lists, links, or blockquotes. They are a supporting technique. If you want a blockquote to change its border color based on a citation source type, set a custom property like --quote-type: warning and use a style query to apply the color. This avoids duplicating the blockquote in multiple classes.
blockquote.alert {
--quote-type: warning;
}
@container style(--quote-type: warning) {
blockquote.alert {
border-left-color: #e74c3c;
}
}
The style query requires the container to have a container-type set. For style queries, the container can be any ancestor with container-type: style or container-type: inline-size. This feature is not yet Baseline High. Test for support before relying on it. The fallback is class-based styling. It is simpler and works everywhere.
Common Failure Modes and Debugging Routes
Custom Properties And Silent Failures
When a custom property does not update, the first check is the inheritance chain. A custom property set on a parent is inherited by children. If you set it on a child that does not match the selector, or if you use a typo in the var() fallback, the property silently fails. The var() function without a fallback returns the initial value (guaranteed-invalid). That makes the declaration use the property's initial value. Often not what you expect.
A transition not firing usually means the property value did not change in a way that allows interpolation. Transitioning from auto to 0 does not work because auto is not a numeric value. Similarly, display does not transition. You must transition an opacity or transform instead. The initial value must be set before the target value. If you set the target in the same style block as the initial, the browser sees no change.
Has(), Text-Wrap, And Font Axes
The :has() selector failing to match often comes from an invalid selector inside the parentheses, or from the browser not supporting :has() at all. Older iOS Safari versions locked to a specific device may not have :has() even if the desktop browser does. Use a feature query with @supports selector(:has(a)) to test before applying.
text-wrap: balance has no effect on single-line text. It only distributes text evenly across multiple lines. For a heading that fits on one line, the property does nothing. Similarly, text-wrap: pretty only affects the last line of a block. It does not change the first line.
Font variation axes not responding often mean the font file does not contain the requested axis. Check the font's metadata with a tool like Font Squirrel's web font generator. Also ensure you have a fallback font-weight that matches the closest standard weight. The browser uses that for synthetic bolding when the axis is missing.
Accessibility legal requirements vary by jurisdiction. WCAG 2.2 is adopted into law in different ways: Section 508 in the United States, EN 301 549 in the European Union, AODA in Ontario, and JIS X 8341 in Japan. The specific requirements for link styling and focus indicators may differ slightly. Verify with your legal counsel. The WCAG spec itself is not law. The adopted version is.
text-wrap balance and pretty for Headings and Blockquotes
Balance For Headlines, Pretty For Paragraphs
text-wrap: balance distributes text evenly across lines. Ideal for headlines and short paragraphs. It only works when the text spans multiple lines. A single-line heading is unaffected. The property is browser-native. You do not need a JavaScript polyfill. text-wrap: pretty optimizes the last line of a paragraph to avoid a single orphan word, reducing raggedness. Both are progressive enhancements. The fallback is normal wrapping.
Browser support for text-wrap: balance is not yet Baseline High, but it is widely available in Chromium and Firefox. Safari supports it as of version 16.4. For blockquotes, text-wrap: pretty is a good choice. It reduces the chance of a lone word on the last line, which looks unpolished. Combine it with hanging-punctuation for a refined look. Remember that hanging-punctuation is also not universally supported.
Here is a sample that applies both to a blockquote:
blockquote.fancy {
text-wrap: balance; /* for short quotes */
text-wrap: pretty; /* if balance unsupported, pretty applies */
hanging-punctuation: first;
font-family: "Source Serif 4", "Georgia", serif;
font-display: swap;
}
Because the property list for text-wrap only accepts one value, you cannot set both balance and pretty simultaneously. Use a @supports query to set one or the other. The pretty value degrades to normal in browsers that do not support it. There is no rendering break.
The One Question This Page Answers, and Who It Hurts
The direct answer to the core question, how to style lists, links, and blockquotes with modern CSS, lives in the sections above. What remains is a note on who should use these techniques and who should not. If you are a front-end developer building a content-heavy site with long reading passages, the text-wrap: pretty and hanging-punctuation properties will improve the typographic polish without adding JavaScript. If you are a designer who needs custom list bullets that scale with the font, ::marker is the right tool. But only if your design does not require background images or padding on the marker.
If you are relying on ::marker for a complex visual like a badge with a border, you will be disappointed. The allowed-properties list is too restrictive. Use a ::before pseudo-element instead. If you are building a site that must support older browsers without :focus-visible, use a fallback that shows an outline on :focus as well. Then hide it on mouse interaction with a media query for @media (hover: hover) and (pointer: fine). The cost of that fallback is minimal.
The failure case for these techniques is the same for all CSS: the browser that does not support the property. For ::marker, the fallback is list-style-type with a predefined counter style. For text-decoration-thickness, the fallback is the default underline thickness. That is acceptable. For quotes, the fallback is the browser's language default. It works in any browser. There is no situation where these properties break the layout entirely. They degrade to a readable state. That is the beauty of progressive enhancement.
If you are on a trip and your internet connection drops, these CSS properties still render fine. They are part of the browser's stylesheet engine, not network-dependent. The only network dependency is the custom font, which you control with font-display: swap to show a fallback immediately. You will never lose the quotation marks or the link underlines because of a network failure.
Who This Page Serves and Who Should Look Elsewhere
This guide serves the front-end developer who needs accurate, sourceable details about CSS properties without guessing. It also serves the technical writer documenting a design system who must know the exact fallback behavior of ::marker or the privacy limits of :visited. If you are a beginner learning CSS from scratch, this guide assumes you already know how to write a selector and a declaration. The MDN CSS first-steps guide is a better starting point. Return here once you can read the samples without looking up what a pseudo-element is.
If you are debugging a JavaScript state bug in React, this is not the page for you. Nothing about CSS styling lists or links will help you find a stale closure or a missing dependency in useEffect. The only connection is the custom property trick mentioned earlier. That is a styling workaround, not a state management solution. Go to a JavaScript page first.
For the traveler metaphor that this site uses: you are on a well-marked trail with a reliable map. The destination is the standard route that every browser implements. The adventurous path, using ::marker for a full graphic bullet, is closed because the property set is too narrow. The safe route is the ::before pseudo-element. It has been supported since IE8. If you are a designer who wants to push the limits of what a list marker can do, you will be frustrated. If you are a pragmatic developer who values predictability, you will find everything you need.
The quotes property with content: open-quote is the only way to get language-aware quotation marks without a server-side template. Most sites that hardcode curly quotes break when translated to German, where the opening quote is low and the closing quote is high.