Mert Özen Further With Every Line
Frontend CSS Properties: A Hands-On Guide — #10

Pseudo-elements and Generated Content: ::before/::after, content, Counters, @counter-style, ::marker and list-style

Mert Özen Nov 15, 2026 13 min 16 views
Pseudo-elements and Generated Content: ::before/::after, content, Counters, @counter-style, ::marker and list-style

::before and ::after exist only when the content property has a value other than none; generated content reaches screen readers but is not copied or searched, so it is for decoration and numbers while information stays in the HTML. The post measures alternative text (content: "★" / ""), nested numbering with counters(), a @counter-style for the Turkish alphabet, string markers and the Safari limit of ::marker, and Bookstore's checkout steps.

Version note: This post follows the CSS Pseudo-Elements Level 4, CSS Generated Content Level 3, CSS Lists and Counters Level 3 and CSS Counter Styles Level 3 specifications and the November 2026 state of Baseline (web-features 3.46.0); the examples were run in Chromium 158 and WebKit 27.2 (Playwright 1.65), and Firefox support comes from web-features and MDN browser-compat-data 8.1.9.

Guess First

For Bookstore, the online bookstore used as the shared example across this site's series, we will add numbered steps to the checkout page, a reading list with custom markers to the catalog, and a small folded corner to the "New" badge. All of it is done with content generated by CSS, without touching the markup. First, a situation almost every beginner runs into once:

/* 1) no content, no pseudo-element */
.dot::before { display: inline-block; inline-size: 20px; block-size: 10px; background: #b91c1c; }
#with-content::before { content: ""; }

It looks as if the ::before of both span elements will be a 20×10 red box. Does the box show up in both?

No content, No Pseudo-element

::before and ::after are boxes added at the start and the end of an element's content. For the box to exist, the content property must have a value other than none or normal; an empty string counts. The measurement in the lab:

node scripts/10-generated.mjs
no-content   ::before content none, span width 38
with-content ::before content "", span width 58

On the span without content, the computed value is none and the span is only as wide as its text, 38 pixels. With content: "" the box exists and the span grows by 20 pixels to 58. The answer to the opening question: only in the second. For a pseudo-element that never shows up, content is the first place to look.

The second rule: replaced elements such as img, input and video (elements whose content is defined by an external resource, not by CSS) have no content, so ::before and ::after have nowhere to go:

/* 2) replaced elements have no ::before (only a broken image may show one) */
.cover::before { content: ""; position: absolute; inset: 0; background: #b91c1c; }
.cover { position: relative; display: block; inline-size: 60px; block-size: 90px; }
--- chromium
cover-ok     pixel (5, 5): rgb(193, 120, 40)
cover-broken pixel (5, 5): rgb(185, 28, 28)
--- webkit
cover-ok     pixel (5, 5): rgb(191, 116, 33)
cover-broken pixel (5, 5): rgb(153, 158, 164)

On the loaded cover, the top left pixel is the cover's orange in both engines; the red ::before is not painted. On the cover whose file cannot be found, Chromium paints the ::before (red), because it shows its own content (the alt text) in place of the broken image; WebKit paints its own broken-image frame (gray). Do not rely on this behavior; if you want to decorate an image, put the decoration on a pseudo-element of a wrapper element.

content Values and Alternative Text

content can take several parts side by side: strings, the value of an attribute with attr(), counter() and counters(), an image with url(), open-quote and close-quote (quotation marks based on the language and the quotes property). Generated content does not enter the DOM, but it does reach screen readers. To see this, I read Chromium's accessibility tree (the structure screen readers read) through the DevTools protocol; this part works only in Chromium:

<button class="badge" id="badge-plain">New</button>
<button class="badge" id="badge-alt">New</button>
<a class="ext" id="ext" href="https://example.com/dune">Publisher page</a>
/* 3) decorative or meaningful? the part after / is the alternative text */
.badge::before { content: "★ "; }
#badge-alt::before { content: "★ " / ""; }
.ext::after { content: " (" attr(href) ")"; }
accessible names: badge-plain "★ New", badge-alt "New", ext "Publisher page (https://example.com/dune)"
ext innerText: "Publisher page"

The accessible name of the first button is "★ New": the star is read too, and a screen reader may announce it as something like "black star". On the second button we wrote content: "★ " / ""; the part after the slash is the alternative text, and since it is empty, the name is just "New". Leave the alternative text empty for decorative generated content; for content that carries information (saying "completed" with an icon, for example), write the meaning in the alternative text. The alternative text syntax has been Baseline since 2024.

The link example shows the other direction. The URL added with attr(href) becomes part of the link's name in the accessibility tree: "Publisher page (https://example.com/dune)". innerText, however, returns only "Publisher page"; generated text is not copied, not found by in-page search and not read by JavaScript. That is why information the user needs (a price, stock status, a warning) must not come from content; it belongs in the HTML. This classic use of attr() (inserting the value as text) has worked everywhere for a very long time; the newer attr() that uses the value as a number, color or length in other properties is not Baseline (missing in Safari).

Counters and @counter-style

CSS counters are variables that count elements. counter-reset starts a counter (on a parent element), counter-increment increases it, and counter-set sets it to a given value. counter() writes a single value; counters() joins the value of every nesting level with a separator:

<ol class="steps" id="steps">
    <li>Cart</li>
    <li>Address
        <ol class="steps">
            <li>Billing address</li>
            <li>Shipping address</li>
        </ol>
    </li>
    <li>Payment</li>
</ol>
/* 4) counters: nested numbering with counters() */
.steps { counter-reset: step; list-style: none; padding: 0; }
.steps li { counter-increment: step; }
.steps li::before { content: counters(step, ".") ". "; }
steps:   1. Cart | 2. Address | 2.1. Billing address | 2.2. Shipping address | 3. Payment

Each ol in the nested list opens a new counter with the same name through its own counter-reset, and counters() joins all levels with a dot: 2.1 and 2.2. We turned off the list markers with list-style: none, and ::before writes the numbers. Because I cannot read the accessibility tree in WebKit, I checked the same numbers there only in a screenshot.

When the built-in counter styles (decimal, lower-alpha, upper-roman…) are not enough, you define your own with @counter-style. On a Turkish quiz page the options should go "a) b) c) ç)", but lower-alpha uses the English alphabet and skips "ç":

/* 5) a counter style for the Turkish alphabet */
@counter-style turkish-alpha {
    system: alphabetic;
    symbols: a b c ç d e f g ğ h ı i j k l m n o ö p r s ş t u ü v y z;
    suffix: ") ";
}
#answers { list-style: turkish-alpha; }
<ol id="answers"><li>Ankara</li><li>Izmir</li><li>Bursa</li><li>Konya</li></ol>
answers: a) Ankara | b) Izmir | c) Bursa | ç) Konya

The fourth option is "ç)": system: alphabetic counts through the symbol list like letters (after z come "aa", "ab"…), and suffix is added after every marker. WebKit showed the same letters in a screenshot. @counter-style has been widely available since March 2026; it arrived with Safari 17.

list-style, String Markers and ::marker

There are three levels for list markers. list-style-type takes a built-in style or the name of a @counter-style; it can also take a string, and that string becomes the marker itself. list-style-position: inside moves the marker into the line. ::marker selects the marker's box and lets you change its color, its font, and even its content:

/* 6) markers: a string as the marker, ::marker for color and content */
.marks { list-style-position: inside; padding: 0; }
#string-marker { list-style-type: "– "; }
#marker-content li::marker { content: "→ "; color: #b91c1c; }
--- chromium
default-marker text starts at x=22, red marker pixels: 0
string-marker  text starts at x=13, red marker pixels: 0
marker-content text starts at x=18, red marker pixels: 14
--- webkit
default-marker text starts at x=15, red marker pixels: 0
string-marker  text starts at x=13, red marker pixels: 0
marker-content text starts at x=18, red marker pixels: 14

Because the marker is inside the line, where the text starts shows how wide the marker is. The default bullet takes 22 pixels in Chromium and 15 in WebKit; the string marker ("– ") takes 13 in both. The "→ " set through ::marker takes 18 pixels in both engines and is red (14 red pixels). A warning is needed here: according to browser-compat-data, Safari supports only color and font-size on ::marker, which is why web-features counts ::marker as "not Baseline". Playwright's WebKit 27.2 build applied content as well, but that is not the released Safari; I could not verify it in real Safari. The safe route: give the marker's text through a list-style-type string, and use ::marker only for color and font size.

list-style: none also has an accessibility side. WebKit uses heuristics and does not report some lists without markers as lists; VoiceOver then does not say "list, 3 items". WebKit kept this as a deliberate decision in 2021, and in December 2025 the same behavior was reported again for VoiceOver on iOS. I did not test this with VoiceOver on this machine. Give unmarked lists that must be known as lists role="list"; another way is an empty string marker instead of list-style: none (list-style-type: "").

Checkout Steps and the Reading List

The step indicator on Bookstore's checkout page shows the numbers in circles. Because ::marker cannot draw a box (it takes no border, background or size), the list does its own counting:

<ol class="checkout-steps" role="list">
    <li class="is-done">Cart</li>
    <li aria-current="step">Shipping</li>
    <li>Payment</li>
</ol>
/* numbers in circles: ::marker cannot draw a box, so the list counts by itself */
.checkout-steps {
    display: flex;
    gap: 1.5rem;
    margin: 0;
    padding: 0;
    list-style: none;
    counter-reset: step;
}
.checkout-steps li {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    color: var(--color-muted);
    counter-increment: step;
}
.checkout-steps li::before {
    content: counter(step);
    display: grid;
    place-items: center;
    inline-size: 1.75rem;
    block-size: 1.75rem;
    border: 2px solid currentColor;
    border-radius: 50%;
    font-weight: 700;
}
/* the check mark is decoration; the text after the slash is what screen readers get */
.checkout-steps .is-done::before { content: "✓" / "Completed:"; }
.checkout-steps [aria-current="step"] { color: var(--color-link); font-weight: 600; }
node scripts/10-pages.mjs
--- chromium
steps ::before: "✓" / "Completed:" | counter(step) | counter(step)
current step circle: 28px x 28px, border-radius 50%, color rgb(29, 78, 216)
steps for screen readers: Completed: Cart | 2 Shipping | 3 Payment
badge ::after content "", pixel (width - 2, 4) rgb(255, 255, 255), bottom left pixel rgb(185, 28, 28)
badge text for screen readers: "NEW"
reading list markers: "✓ " | "○ " | "○ "
red marker pixels left of "Emma": 15
reading list for screen readers: ✓ Emma finished | ○ Middlemarch | ○ Persuasion
::selection background: rgba(29, 78, 216, 0.2)
--- webkit
steps ::before: "✓" / "Completed:" | counter(step) | counter(step)
current step circle: 28px x 28px, border-radius 50%, color rgb(29, 78, 216)
badge ::after content "", pixel (width - 2, 4) rgb(255, 255, 255), bottom left pixel rgb(185, 28, 28)
reading list markers: "✓ " | "○ " | "○ "
red marker pixels left of "Emma": 14
::selection background: rgba(29, 78, 216, 0.2)

The first three lines are about the steps. The completed step shows a check mark instead of a number, with "Completed:" as its alternative text; in Chromium's accessibility tree the steps read "Completed: Cart", "2 Shipping", "3 Payment". The check mark itself is not read; its meaning is. The current step is marked with aria-current="step", and the same attribute is the selector for its style; the state lives in one place. The circles are 28×28 and take their border color from the text through currentColor. Since we wrote list-style: none, the ol got role="list".

In the reading list the markers are strings: "✓ " for the finished book, "○ " for the others. ::marker gives them their color; that is the part Safari supports as well:

<section class="reading-list">
    <h2>My reading list</h2>
    <ul>
        <li class="is-read">Emma <small>finished</small></li>
        <li>Middlemarch</li>
        <li>Persuasion</li>
    </ul>
</section>
/* a string as the marker: no image, no ::before */
.reading-list ul { padding-inline-start: 1.5rem; list-style-type: "○ "; }
.reading-list .is-read { list-style-type: "✓ "; }
/* Safari styles only color and font-size on ::marker */
.reading-list li::marker { color: var(--color-accent); }
.reading-list small { color: var(--color-muted); }

In the page measurement there are 14 to 15 red pixels to the left of "Emma": the marker is painted in the accent color in both engines. Chromium's accessibility tree reads the markers too: "✓ Emma finished". So that the marker does not carry information alone, the word "finished" is in the text as well; how a screen reader reads the "✓" character varies by product.

The last piece is the fold in the top right corner of the "New" badge. It is pure decoration, so content: "":

/* decorative folded corner: empty content, nothing for screen readers */
.badge::after {
    content: "";
    position: absolute;
    inset-block-start: 0;
    inset-inline-end: 0;
    border-style: solid;
    border-width: 0 0.5rem 0.5rem 0;
    border-color: transparent var(--color-surface) rgb(0 0 0 / 0.25) transparent;
}

The pixel inside the fold is the card's white, the rest of the badge is red. In the accessibility tree the badge's text is only NEW; the empty content adds nothing. The uppercase comes from text-transform: uppercase: Chromium reports the transformed text (the Turkish-text side of that decision is in post 03 of the UI/UX Design Principles series).

Other Pseudo-elements

::placeholder selects an input's placeholder text; most of the time you only change its color and make sure the contrast stays sufficient. ::file-selector-button lets you style the button inside <input type="file"> and has been Baseline since 2023. ::first-line selects the first line of a block, and the selected text changes when the line width changes; it is the sibling of the ::first-letter from the second post of the series. ::backdrop selects the layer behind a dialog in the top layer; the top layer is in post 04 of the CSS3 series, and <dialog> in post 06 of the HTML5 and Semantic HTML series.

::selection changes the color of the text the user selects. It works in every desktop browser, but because iOS Safari does not support it, web-features lists it as "not Baseline". Where it is not supported, the system's own selection color remains, so it is a harmless enhancement:

/* not Baseline (no iOS Safari), harmless where it is missing */
::selection { background: rgb(29 78 216 / 0.2); }

The last line of the measurement: the ::selection background of h1 is rgba(29, 78, 216, 0.2) in both engines.

In Production

Generated content is invisible to translation tools, in-page search and copying; if Bookstore becomes multilingual, text such as "Completed:" inside content stays untranslated. Keep visible and read text in the HTML, and generate only decoration and numbers in CSS. The exception is alternative text: it turns a decorative mark into a meaningful word, and that word cannot come from the translation files either. In a multilingual project, providing it through a custom property is one solution (content: "✓" / var(--label-completed)); themes and custom properties are in post 10 of the CSS3 series.

Counters work across a page too: to number the headings of an article as "1.", "1.1.", a counter-increment on h2 and counters() on h3 are enough. Print styles are the subject of this series' last post.

Common Pitfalls

First, forgetting to write content. Even if every other property of the pseudo-element is right, no box is created.

Second, giving meaningful information through content. A price, a warning or a status is not copied, not searched and not read by some assistive technologies; the information belongs in the HTML.

Third, not noticing that a decorative mark is read by screen readers. Leave the alternative text empty with content: "★" / "".

Fourth, trying to add ::before to an img or an input. Pseudo-elements are not painted on replaced elements; use a wrapper.

Fifth, losing a list's semantics with list-style: none. Add role="list" to unmarked lists that must still be known as lists.

Baseline Status

node scripts/baseline.mjs before-after alt-text-generated-content counters counter-set counter-style list-style marker placeholder selection file-selector-button first-line
before-after               widely 2018-01-29  Chrome 1, Firefox 1.5, Safari 4, iOS 3.2
alt-text-generated-content newly 2024-07-09   Chrome 77, Firefox 128, Safari 17.4, iOS 17.4
counters                   widely 2018-01-29  Chrome 2, Firefox 1, Safari 3, iOS 1
counter-set                widely 2026-06-11  Chrome 85, Firefox 68, Safari 17.2, iOS 17.2
counter-style              widely 2026-03-18  Chrome 91, Firefox 33, Safari 17, iOS 17
list-style                 widely 2018-01-29  Chrome 1, Firefox 1, Safari 1, iOS 1
marker                     not Baseline       Chrome 86, Firefox 80, Safari -, iOS -
placeholder                widely 2022-07-15  Chrome 57, Firefox 51, Safari 10.1, iOS 10.3
selection                  not Baseline       Chrome 1, Firefox 62, Safari 1.1, iOS -
file-selector-button       widely 2023-10-26  Chrome 89, Firefox 82, Safari 14.1, iOS 14.5
first-line                 widely 2018-01-29  Chrome 1, Firefox 1, Safari 1, iOS 1
FeatureStatusNote
::before, ::after, content, countersWidely availableNo box without content
Alternative text (content: "★" / "")Newly available (2024-07)Safari 17.4, Firefox 128
counter-set, @counter-styleWidely availableWidely in 2026-06 and 2026-03
list-style-type stringsBroadly supportedbrowser-compat-data: Chrome 79, Firefox 39, Safari 14.1
::markerNot BaselineOnly color and font-size in Safari
::selectionNot BaselineMissing in iOS Safari; a harmless enhancement
::placeholder, ::file-selector-button, ::first-lineWidely availableWatch the contrast of placeholders

A Small Experiment

★☆☆ Rewrite the star of #badge-plain in the lab with alternative text that screen readers read as "Featured", and check the accessible name printed by 10-generated.mjs.

★★☆ Add a line between the checkout steps (with ::after, without breaking the existing ::before) and verify that the accessibility tree line of 10-pages.mjs does not change.

★★★ Turn "My reading list" into an ol, write its numbers as "Book 1, Book 2" with @counter-style (the prefix descriptor), and give the marker the accent color with ::marker; measure where the text starts in both engines.

In the next post we move on to overflow and scrolling: the difference between overflow: clip and hidden, scroll snap to scroll the "Recommended" strip without JavaScript, scroll-padding for anchor targets hidden under a sticky header, overscroll-behavior and scrollbar styling.