without a single line of JavaScript
Staggered chapter counters, nested lists with mixed styles, custom markers for list items: pure CSS handles all of it with counter-reset, counter-increment, and counter(). Once you understand the system, you can strike JavaScript counters from your toolkit for good.
Table of Contents
- 1. What CSS counters actually do
- 2. counter-reset and counter-increment: the basics
- 3. counter() and counters() in content properties
- 4. Nested counters: counters() for hierarchical numbering
- 5. ::marker and custom markers
- 6. @counter-style: defining your own counting systems
- 7. Practical patterns: chapter counters, footnotes, progress steps
- 8. Limitations and browser compatibility
- 9. CSS Counter compared directly
- 10. Summary
- 11. FAQ
1. What CSS Counters Actually Do
The CSS Counter is one of the oldest, and at the same time one of the most frequently overlooked, features of CSS. It has been available in every browser since CSS2.1, and it elegantly solves a problem many developers still reach for JavaScript to fix today: automatic, dynamic numbering of elements on a page. A CSS Counter is a named variable that the browser increments while rendering the document, and its value can be output through the content property on pseudo-elements.
What many people do not realize: CSS Counters are not limited to ordered lists. They can be applied to any element at all: sections, tables, code blocks, steps in an onboarding flow, or footnotes in an article. That makes CSS Counter a universal numbering tool that runs entirely in the browser, requires no JavaScript, and keeps working correctly even with dynamically loaded content, as long as the DOM gets updated.
2. counter-reset and counter-increment: The Basics
The CSS Counter system consists of two core declarations: counter-reset and counter-increment. Writing counter-reset: chapter initializes a new CSS Counter named chapter and sets it to 0. You can optionally supply a starting value: counter-reset: chapter 2 starts at 2. The reset declaration also creates a new instance of the counter within the scoping context of the element, a concept that turns out to be essential for nested counters.
counter-increment: chapter increases the counter by the default step of 1 whenever the corresponding element is rendered. A custom step value is possible here too: counter-increment: chapter 2 counts in steps of two. Negative values are allowed: counter-increment: countdown -1 produces a counter that counts down. Multiple counters can be combined in a single declaration: counter-reset: chapter section 0 initializes both at once. This flexibility enables complex, hierarchical numbering schemes described entirely in CSS.
/* CSS Counter: chapter and section numbering */
body {
/* Initialize the chapter counter at document root */
counter-reset: chapter;
}
h2 {
/* Increment chapter counter on every h2 */
counter-increment: chapter;
/* Reset section counter whenever a new chapter starts */
counter-reset: section;
}
h3 {
/* Increment section counter on every h3 */
counter-increment: section;
}
/* Output: "Chapter 2." before each h2 */
h2::before {
content: "Chapter " counter(chapter) ". ";
font-weight: bold;
color: #7c3aed;
}
/* Output: "2.3 " before each h3, a nested counter */
h3::before {
content: counter(chapter) "." counter(section) " ";
color: #4a1d96;
}
/* Reverse counter: count down from 10 */
.countdown-list {
counter-reset: countdown 11;
}
.countdown-list li {
counter-increment: countdown -1;
}
.countdown-list li::before {
content: counter(countdown);
}
One important detail when working with CSS Counter: the increment happens the moment the element itself is rendered, not when the pseudo-element is rendered. That means ::before and ::after on the same element display the same counter value. If a counter needs to be incremented inside an element's ::before pseudo-element before it is output, counter-increment must sit on the element itself, not on the pseudo-element.
3. counter() and counters() in content Properties
The current value of a CSS Counter is retrieved with the function counter(name). This function is only valid inside the content property of pseudo-elements; it cannot be used in other CSS properties such as font-size or color. The function returns the counter value as a string, which can then be combined with other strings through concatenation: content: "Step " counter(steps) " of 5". That makes CSS Counter a readable, maintainable way to generate labels without JavaScript.
counter(name, listStyle) optionally accepts a second parameter that determines the output format. The default is decimal. Possible values include lower-alpha, upper-roman, lower-roman, lower-greek, and every value permitted for list-style-type, including custom styles defined with @counter-style. Writing counter(section, lower-roman) outputs the CSS Counter as a roman numeral without changing the counter itself. The format is purely presentational and has no effect on the internal counter value.
4. Nested Counters: counters() for Hierarchical Numbering
Nested numbering such as "1.2.3" is possible with CSS Counters without any JavaScript. The key lies in the function counters(name, separator): note the plural s. counters() walks through every instance of the same-named counter in the nesting hierarchy and joins them with the given separator. content: counters(item, ".") ". " produces the output "1.2.3." on a triple-nested element.
The scoping behavior of CSS Counters is what makes this work: counter-reset on an element creates a new, local instance of the counter, while the outer instance stays intact. counters() outputs every instance, from outermost to innermost. This behavior makes nested CSS Counters especially valuable for tables of contents, technical documentation, and multi-step guides. The implementation needs only two CSS rules: counter-reset on the list element and counter-increment paired with counters() output on the list item.
/* Hierarchical numbering with counters(): "1.2.3." style */
ol.outline {
/* Create a new scope for each nested list */
counter-reset: outline-item;
list-style: none;
padding-left: 1.5rem;
}
ol.outline li {
counter-increment: outline-item;
margin-bottom: 0.5rem;
}
/* counters() walks up the nesting and joins with "." */
ol.outline li::before {
content: counters(outline-item, ".") ". ";
font-weight: 600;
color: #7c3aed;
margin-right: 0.5rem;
}
/* Nested ol automatically creates a child counter scope */
/* Result: 1. / 1.1. / 1.1.1. / 2. / 2.1. etc. */
/* Separate counters for figures and tables */
article {
counter-reset: figure-num table-num;
}
figure {
counter-increment: figure-num;
}
figure figcaption::before {
content: "Figure " counter(figure-num) ": ";
font-weight: bold;
font-style: normal;
}
table {
counter-increment: table-num;
}
table caption::before {
content: "Table " counter(table-num) ": ";
font-weight: bold;
}
5. ::marker and Custom Markers
The ::marker pseudo-element lets you style a list marker directly without replacing the ::before pseudo-element. Before ::marker existed, customizing bullet points meant a compromise: either list-style: none combined with manually set ::before content, or accepting the browser's default formatting. ::marker now lets you override color, font size, font family, and, most importantly, the marker's content value directly. That opens the door to using a CSS Counter directly inside the ::marker context.
With ::marker { content: counter(list-item) ". "; color: #7c3aed; } the built-in list-item counter gets reformatted. The list-item counter is a predefined CSS Counter that browsers automatically increment for li elements; you never need to initialize it manually. The combination of ::marker and the built-in list-item counter creates the cleanest way to style list numbering: the browser handles the counting, and the stylesheet handles only the appearance.
6. @counter-style: Defining Your Own Counting Systems
With @counter-style you can define entirely custom counting systems. The at-rule takes a name plus descriptors such as system, symbols, suffix, prefix, and range. The result is a new counting system usable anywhere a list style value is accepted, meaning in list-style-type, in counter(name, my-style), and directly inside ::marker. Emoji counters, Japanese numbering, custom alphabets: all of it is possible without JavaScript.
The system: cyclic system repeats its symbols once the list grows longer than the defined set of symbols. system: numeric behaves like the decimal system but with custom digits. system: alphabetic matches alphabetic list counting (a, b, c... z, aa, ab...). system: additive lets you build additive counting systems, like roman numerals, yourself. This flexibility makes @counter-style the most powerful, and least known, extension of the CSS Counter system.
/* Custom counter style: emoji checkboxes cycling 3 symbols */
@counter-style check-steps {
system: cyclic;
symbols: "✦" "◆" "▶";
suffix: " ";
}
/* Custom counter style: padded decimal (01, 02, ... 10) */
@counter-style padded-decimal {
system: numeric;
symbols: "0" "1" "2" "3" "4" "5" "6" "7" "8" "9";
/* prefix and suffix are added around the counter value */
prefix: "0";
range: 0 9; /* only for single-digit numbers */
}
/* Use @counter-style in a list */
ol.steps {
list-style-type: check-steps;
counter-reset: list-item 0;
}
/* Reference in counter() output */
.numbered-section::before {
content: counter(chapter, padded-decimal) ". ";
color: #7c3aed;
font-variant-numeric: tabular-nums;
}
/* ::marker with custom counter style */
ol.process-steps li::marker {
content: counter(list-item, upper-roman) ". ";
color: #4a1d96;
font-weight: bold;
font-size: 0.9em;
}
7. Practical Patterns: Chapter Counters, Footnotes, Progress Steps
In practice, CSS Counters prove themselves in three concrete scenarios. First, chapter counters in long articles or documentation. With a counter initialized on body and counter-increment applied to h2 elements, every heading is numbered automatically, with no server-side generation and no JavaScript. If a section is removed or moved, every following number adjusts automatically. That is a real maintainability win over manually numbered headings.
Second, footnote references. A CSS Counter initialized on body, incremented on every element with the class .footnote-ref, and output both inside sup::before and in the footnote line through counters(), produces consistent, automatically numbered footnotes in pure CSS. Third, progress steps in forms or onboarding flows. Here the CSS Counter fully replaces JavaScript-based step numbering, with the added benefit that the numbering stays correct even when steps are hidden through CSS, as long as display: none interrupts the increment.
8. Limitations and Browser Compatibility
CSS Counters come with defined limitations. The most important one: counters cannot be read from JavaScript. The current counter value is not accessible through the DOM or the CSSOM API; it exists only within the browser's rendering context. Anyone who needs to process the counter value further in JavaScript, for analytics or server-side logic, for example, cannot do so directly. A second limit: CSS Counters do not cross Shadow DOM boundaries. Counters are maintained within the light DOM of a document, and Shadow DOM keeps its own counter context.
As for browser compatibility: counter-reset, counter-increment, and counter() have been supported everywhere for a long time. @counter-style has been available since Chrome 91, Firefox 33, and Edge 91. Safari has traditionally lagged behind and only supported it starting with Safari 17. ::marker with content overrides has been available since Chrome 86, Firefox 68, and Safari 11.1, though support for individual properties inside the ::marker context varies. For production use, a progressive enhancement approach is recommended: standard list styles as a fallback, CSS Counter as the enhancement.
9. CSS Counter Compared Directly
The choice between CSS Counter, JavaScript-based numbering, and server-generated numbers depends on the specific use case. For purely visual, document-based numbering, think chapters, sections, figures, CSS Counters are the most maintainable solution. For numbering used inside business logic, JavaScript remains necessary.
| Task | JavaScript Approach | CSS Counter | CSS Advantage |
|---|---|---|---|
| Numbering chapters | querySelectorAll + textContent |
counter-reset on body, counter-increment on h2 |
No JS, no layout shift |
| Nested lists | Recursive DOM traversal | counters(item, ".") |
Hierarchy is automatic, no code |
| Footnotes | Calculate index, patch DOM | Counter on .footnote-ref |
Automatic on DOM changes |
| Custom marker symbol | Set innerHTML for every li | ::marker { content: … } |
Declarative, no DOM mutation |
| Reverse counter | Array length minus index | counter-increment: c -1 |
Declarative in a single line |
One argument for CSS Counter is often overlooked: it works right at first render, with no JavaScript needing to load and execute. That means no delay, no flash of bare numbers, and no dependency on the JavaScript runtime. In environments where JavaScript is blocked or broken, the numbers stay correct. For documentation pages, technical articles, and forms, that is a genuine robustness advantage.
Mironsoft
Modern CSS, frontend architecture, and Hyva theme development
Clean CSS instead of JavaScript workarounds?
We analyze your frontend code, identify unnecessary JavaScript dependencies, and replace them with maintainable, high-performance CSS solutions, from counters to custom properties to modern layout techniques.
CSS Audit
Analysis of unnecessary JavaScript dependencies and CSS potential in your frontend
Refactoring
Replace JS with modern CSS features: counters, custom properties, animations
Hyva Themes
Magento 2 frontend with Hyva, Tailwind CSS, and Alpine.js, no Luma baggage
10. Summary
The CSS Counter is an underrated, powerful tool for automatic numbering without JavaScript. counter-reset initializes a counter, counter-increment increases it on every matching element, and counter() outputs the value inside pseudo-elements. Nested hierarchies are handled with counters(): the function joins every level automatically with a separator. @counter-style extends the system with entirely custom counting systems built from custom symbols, prefixes, and suffixes.
For practical use, the rule of thumb is: CSS Counters are the first choice for visual, document-based numbering. They render without JavaScript, respond to DOM changes automatically, and are reliably available in every modern browser. The one real limitation is that the counter value cannot be read from JavaScript; if the value is needed in business logic, it has to be tracked separately. For every purely visual numbering task, the CSS Counter is the most elegant and maintainable solution in the modern web development toolkit.
CSS Counter: The Essentials at a Glance
Initialization
counter-reset: name start-value on an ancestor element. Without a starting value, the counter begins at 0.
Increment
counter-increment: name step-value on the element being counted. Negative values count backward.
Output
content: counter(name) inside ::before or ::after. counters(name, ".") for nested hierarchies.
Extension
@counter-style for custom symbols and systems. ::marker for direct list marker styling.