CSS Counter: Automatic Numbering Without JavaScript
AI generated
CSS · Typography · Lists · Layout
CSS Counter: automatic numbering
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.

10 min read counter-reset · counter-increment · counter() · @counter-style CSS3 · All modern browsers

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.

11. FAQ: CSS Counter and Automatic Numbering

1What is a CSS Counter?
A named variable in the browser's rendering context: initialized with counter-reset, incremented with counter-increment, and output via counter() in pseudo-elements. No JavaScript required.
2counter() vs. counters()?
counter() outputs a single counter. counters() joins every counter instance in the nesting hierarchy with a separator, for "1.2.3." style numbering.
3Read a CSS Counter from JavaScript?
Not possible. CSS Counters exist only in the rendering context and cannot be read through the DOM or CSSOM.
4A CSS Counter that counts backward?
counter-reset: countdown 11 as the starting value, counter-increment: countdown -1 to count backward. Produces 10, 9, 8 ... 1.
5What does @counter-style do?
A custom counting system with custom symbols, prefixes, and suffixes. Usable in list-style-type and as the second parameter of counter().
6::marker and CSS Counters?
content: counter(list-item) inside ::marker formats the built-in list marker directly, with no list-style: none and no manual ::before required.
7CSS Counters across Shadow DOM?
Not possible. Every DOM tree maintains its own counters. Shadow DOM has its own context, and counters do not cross Shadow DOM boundaries.
8counter-reset: name 0 vs. counter-reset: name?
Identical: without an explicit starting value the counter begins at 0. counter-reset: chapter 2 starts at 2; the first increment produces 3.
9Nested counters not resetting?
counter-reset needs to sit on the ol/ul element that starts the new level, not on li. Otherwise every li gets its own, independent counter instance.
10Browser support for @counter-style?
Chrome 91, Firefox 33, Edge 91. Safari only from version 17 onward. Provide a fallback list-style-type for older Safari versions.