
CSS Counters: Automatic Numbering Without JavaScript
Numbering things by hand is one of those jobs that seems simple until it isn't. You write "Step 1", "Step 2", "Step 3", then someone inserts a new step in the middle, and suddenly every number after it is wrong. Documentation headings like "2.3.1" are even worse. Reach for JavaScript and you've added a script just to count.
CSS has had a better answer for a long time: counters. They're variables maintained by the browser that you can create, increment, and print with generated content. Add or remove an item, and every number updates automatically. No script, no manual edits, no mistakes.
In this guide, I'll show you how counters work, build several real components with them, and cover the gotchas that confuse people the first time they try.
Why Use CSS Counters?
Ordered lists (ol) already number themselves, so why bother with counters? Because lists only cover one case. Counters let you number anything:
- Section headings in long articles and docs (1, 1.1, 1.2, 2, ...)
- Steps in a tutorial or checkout flow
- Figures, tables, and code listings ("Figure 3")
- Custom list markers with your own styling
- Counts of checked items in a form, all in CSS
They also keep numbering purely presentational. The HTML stays clean, and the numbers are generated at render time.
The Four Building Blocks
Counters come down to four pieces:
| Piece | What it does |
|---|---|
counter-reset | Creates a counter (or resets it) on an element |
counter-increment | Adds to the counter each time an element is rendered |
counter() | Prints the counter's current value inside content |
counters() | Prints all nested counters of the same name, joined by a string |
There's also counter-set, which sets a counter to a specific value without creating a new one.
A Minimal Example
<div class="steps">
<p class="step">Create an account</p>
<p class="step">Verify your email</p>
<p class="step">Choose a plan</p>
</div>
.steps {
counter-reset: step;
}
.step {
counter-increment: step;
}
.step::before {
content: "Step " counter(step) ": ";
font-weight: 700;
color: #6366f1;
}
The result:
- Step 1: Create an account
- Step 2: Verify your email
- Step 3: Choose a plan
Here's what happens:
counter-reset: stepon.stepscreates a counter namedstep, starting at 0.- Each
.stepincrements it by 1 as it's rendered. - The
::beforepseudo-element prints the current value.
The counter name is an identifier you invent. step, section, figure, anything works, but avoid list-item, which is a built-in counter reserved for lists.
Custom Start Values and Increments
Both counter-reset and counter-increment accept an optional number:
/* Start from 10, so the first item shows 11 */
.chapter-list {
counter-reset: chapter 10;
}
/* Count by twos */
.even-items li {
counter-increment: item 2;
}
/* Count down */
.countdown {
counter-reset: launch 11;
}
.countdown li {
counter-increment: launch -1;
}
Since the counter is incremented before ::before is rendered, starting at 11 and incrementing by -1 shows 10, 9, 8, and so on.
You can also reset and increment several counters in one declaration:
article {
counter-reset: figure table listing;
}
Numbering Headings
This is the killer use case. Long docs benefit from numbered headings, and counters make them trivial.
<article class="doc">
<h2>Installation</h2>
<h3>Requirements</h3>
<h3>Download</h3>
<h2>Configuration</h2>
<h3>Basic settings</h3>
<h3>Advanced settings</h3>
<h3>Environment variables</h3>
</article>
.doc {
counter-reset: h2;
}
.doc h2 {
counter-increment: h2;
counter-reset: h3;
}
.doc h3 {
counter-increment: h3;
}
.doc h2::before {
content: counter(h2) ". ";
color: #94a3b8;
}
.doc h3::before {
content: counter(h2) "." counter(h3) " ";
color: #94a3b8;
}
Output:
1. Installation
1.1 Requirements
1.2 Download
2. Configuration
2.1 Basic settings
2.2 Advanced settings
2.3 Environment variables
The trick is that each h2 resets the h3 counter, so sub-numbering restarts under every new section.
Why Not Reset the h3 Counter on the Article?
Headings are siblings, not nested elements. When an h2 resets h3, the new counter's scope covers that h2 and its following siblings, so the h3 elements after it can see it. This sibling-scoping behavior is exactly what makes heading numbering work. It's also why you should put counter-reset: h3 on the h2, not on some unrelated wrapper.
Nested Lists with counters()
For genuinely nested structures, like an outline or a legal document, the counters() function (with an s) does the heavy lifting. Every nested ol creates a new instance of the same counter, and counters() prints them all, joined by a separator you choose.
<ol class="outline">
<li>
Introduction
<ol>
<li>Purpose</li>
<li>
Scope
<ol>
<li>In scope</li>
<li>Out of scope</li>
</ol>
</li>
</ol>
</li>
<li>Definitions</li>
</ol>
.outline,
.outline ol {
counter-reset: item;
list-style: none;
padding-left: 1.5rem;
}
.outline li {
counter-increment: item;
}
.outline li::before {
content: counters(item, ".") " ";
font-weight: 600;
color: #6366f1;
margin-right: 0.25rem;
}
Output:
1 Introduction
1.1 Purpose
1.2 Scope
1.2.1 In scope
1.2.2 Out of scope
2 Definitions
The same code handles any depth. Add a fourth level and it becomes 1.2.1.1 automatically.
Counter Styles
Both counter() and counters() accept a style as the last argument. Any value you'd use for list-style-type works:
.appendix h2::before {
content: "Appendix " counter(appendix, upper-alpha) ": ";
}
.roman-steps li::before {
content: counter(step, upper-roman) ". ";
}
.padded li::before {
content: counter(item, decimal-leading-zero) " ";
}
Common styles include decimal, decimal-leading-zero, lower-alpha, upper-alpha, lower-roman, upper-roman, lower-greek, and many language-specific systems like arabic-indic, devanagari, and japanese-formal.
Defining Your Own with @counter-style
If none of the built-in styles fit, @counter-style lets you define your own. It's supported in current major browsers.
@counter-style circled {
system: fixed;
symbols: "①" "②" "③" "④" "⑤" "⑥" "⑦" "⑧" "⑨" "⑩";
suffix: " ";
fallback: decimal;
}
.tips {
counter-reset: tip;
list-style: none;
}
.tips li {
counter-increment: tip;
}
.tips li::before {
content: counter(tip, circled);
}
With system: fixed, the symbols are used in order, and once they run out, the fallback style takes over. Other systems include cyclic (repeat the symbols), numeric, alphabetic, symbolic, and additive.
Real-World Components
A Step Indicator
Numbered circles are a staple of onboarding and checkout flows.
<ol class="progress-steps">
<li class="is-done">Cart</li>
<li class="is-done">Shipping</li>
<li class="is-current">Payment</li>
<li>Review</li>
</ol>
.progress-steps {
counter-reset: progress;
display: flex;
gap: 2rem;
list-style: none;
padding: 0;
}
.progress-steps li {
counter-increment: progress;
display: flex;
align-items: center;
gap: 0.5rem;
color: #64748b;
}
.progress-steps li::before {
content: counter(progress);
display: grid;
place-items: center;
width: 2rem;
height: 2rem;
border-radius: 50%;
border: 2px solid currentColor;
font-weight: 700;
}
.progress-steps .is-done::before {
content: "✓";
background: #34d399;
border-color: #34d399;
color: #fff;
}
.progress-steps .is-current {
color: #4f46e5;
font-weight: 600;
}
Notice that the "done" steps still increment the counter even though they display a checkmark instead of the number, so "Payment" correctly shows 3.
Figure and Table Captions
Academic and technical content often labels figures and tables. Counters keep them in order even as content moves around.
.article {
counter-reset: figure table;
}
.article figure {
counter-increment: figure;
}
.article figcaption::before {
content: "Figure " counter(figure) ". ";
font-weight: 700;
}
.article table {
counter-increment: table;
}
.article caption::before {
content: "Table " counter(table) ". ";
font-weight: 700;
}
One limitation: CSS can't reference a counter elsewhere, so you can't write "see Figure 3" in body text and have it update. For cross-references, you still need a build step or a script.
Counting Checked Items
Counters only increment on rendered elements, and that includes elements matched by state selectors. That lets you count selections with no JavaScript at all:
<fieldset class="toppings">
<legend>Pick your toppings</legend>
<label><input type="checkbox" /> Cheese</label>
<label><input type="checkbox" /> Mushrooms</label>
<label><input type="checkbox" /> Olives</label>
<label><input type="checkbox" /> Peppers</label>
<p class="toppings-total"></p>
</fieldset>
.toppings {
counter-reset: picked;
}
.toppings input:checked {
counter-increment: picked;
}
.toppings-total::after {
content: "Selected: " counter(picked);
font-weight: 600;
}
The total element must come after the checkboxes in the document, because counters are evaluated in document order. Treat this as a visual nicety, though. Generated content isn't reliably announced by all screen readers, so if the count is important, expose it in real text too.
Counters and Ordered Lists: list-item
Browsers implement ol numbering with a built-in counter called list-item. You can use it directly with ::marker:
.fancy-list li::marker {
content: counter(list-item) " → ";
color: #f472b6;
font-weight: 700;
}
This keeps native list semantics, including the start and reversed attributes on ol, while giving you full control over how the number looks.
Common Pitfalls
Resetting in the Wrong Place
If every item shows "1", you've almost certainly put counter-reset on the repeating element instead of its container. Each reset creates a fresh counter, so it never gets past 1.
/* Wrong: resets on every item */
.step {
counter-reset: step;
counter-increment: step;
}
/* Right: reset once on the parent */
.steps {
counter-reset: step;
}
.step {
counter-increment: step;
}
Hidden Elements Don't Count
Elements with display: none are not rendered, so they don't increment counters. That's usually what you want, since filtered items drop out of the numbering. But elements hidden with visibility: hidden or opacity: 0 do still count.
Overwriting counter-increment
counter-increment is a single property. If two rules set it on the same element, the later one wins, and the other counter stops incrementing. Combine them in one declaration:
.doc h2 {
counter-increment: h2 toc-entry;
}
Accessibility
Screen reader support for text generated by ::before and ::after varies. For decorative numbering this is fine. For numbers that carry meaning, like legal clause references, consider whether the numbers should be in the HTML instead, or use a real ol so assistive technology announces list positions.
Browser Support
counter-reset, counter-increment, counter(), and counters() have been supported in every major browser for well over a decade. counter-set and @counter-style are supported in current versions of Chrome, Edge, Firefox, and Safari. Styling list numbers via ::marker with counter(list-item) also works in current browsers, though the set of properties allowed on ::marker is intentionally limited, and support for the content property on ::marker has historically lagged in Safari. Test it there, or fall back to ::before with list-style: none.
Conclusion
CSS counters are one of those features that feel like a small trick until you use them for something real, and then you wonder how you managed without them. Reset a counter on a container, increment it on each item, print it with counter() or counters(), and the browser takes care of the rest.
Use them for numbered headings, nested outlines, step indicators, and figure labels. Style them with the built-in counter styles or your own @counter-style. And keep an eye on where you reset them, because that one detail causes most counter bugs.


