CSS Counters Explained: Auto-Numbering Without JS
CSS counters auto-number elements with counter-reset, counter-increment, and the counter() function — no JavaScript or manual list numbers required.
CSS counters are variables, maintained by the browser, that increment automatically as it walks through your document — letting you number headings, list items, or figures in pure CSS instead of hardcoding numbers or reaching for JavaScript. They’re one of the oldest parts of CSS still underused today, quietly solving problems that people otherwise patch with nth-child tricks or generated markup.
The three pieces: reset, increment, content
A counter has a name you choose, and three properties/functions work together:
counter-reset— declares the counter and sets its starting value (default0) on the element where counting begins.counter-increment— bumps the counter by 1 (or any amount) on every matching element.counter()orcounters()— reads the current value, used inside acontentproperty, almost always on a::beforeor::afterpseudo-element.
A minimal example that numbers <h2> sections:
body {
counter-reset: section;
}
h2::before {
counter-increment: section;
content: "Section " counter(section) ": ";
}
Every h2 in the document gets a live, auto-updating “Section N: ” prefix. Reorder the headings, add one, delete one — the numbers stay correct without touching a single number by hand.
Nested counters with counters()
The plain counter() function only reads one level. For nested structures — like outline-style numbering (1, 1.1, 1.2, 2, 2.1) — use counters(), which takes a separator string and concatenates every ancestor counter of the same name:
ol {
counter-reset: item;
list-style: none;
}
li {
counter-increment: item;
}
li::before {
content: counters(item, ".") " ";
}
Nested <ol> elements each get their own item counter scoped to that list, and counters() joins the parent and child values with a dot, producing 1, 1.1, 1.2, 2, and so on. This is the same mechanism browsers use internally for the default decimal numbering on ordered lists — you’re just making it explicit and customizable.
Custom counter styles
Counters aren’t limited to plain numbers. counter() and counters() accept an optional counter-style keyword (decimal, upper-roman, lower-alpha, disc, and others), or a name registered with @counter-style for fully custom formats:
li::before {
content: counter(item, upper-roman) ". ";
}
That single change turns 1, 2, 3 into I, II, III with no other markup changes. This pairs well with the kind of fine-grained visual control you get from CSS cascade layers when you need predictable specificity across a design system’s numbering rules.
Why not just use list-style or manual numbers
Three common alternatives, and where counters win:
| Approach | Renumbers automatically | Works on non-list elements | Supports nested/multi-level numbering |
|---|---|---|---|
| Hardcoded numbers in content | No | Yes | Manual |
list-style-type | Yes | No (lists only) | No |
| CSS counters | Yes | Yes | Yes (counters()) |
list-style-type handles the common case of numbering an <ol>, but it’s tied to list semantics and offers no nesting control beyond the browser’s defaults. Counters generalize the same idea to any element — headings, <figure> captions, custom card grids — and let you combine multiple counters (e.g., a running total alongside a per-section count) on the same page.
Practical use cases
- Figure and table numbering in long-form documentation, where “Figure 3” needs to stay accurate as sections are edited.
- Outline-style numbering for legal documents, specs, or nested table-of-contents style navigation.
- Custom list markers that go beyond bullets and decimals — Roman numerals for formal documents, or a counter combined with
clamp()-based fluid typography for responsive numbered headings. - Progress indicators, incrementing a counter per step and rendering it as “Step 2 of 5” via
counters()alongside a stored total.
Counters are purely visual and generated via content, so they aren’t part of the accessible name computed for assistive technology in every browser. If the number is meaningful (not just decorative), don’t rely on it as the only signal — pair it with real text content or an aria-label so screen reader users get the same information.
Browser support and fallbacks
Counters have been supported since early CSS2 implementations, so there’s no meaningful compatibility concern in modern browsers. The newer pieces — custom @counter-style definitions and some counter-style keywords — have less universal support than basic decimal/upper-roman/lower-alpha, so test custom styles specifically if you rely on them. As with most CSS custom properties-adjacent features, a sensible fallback is to design the base numbering (plain decimal) to look acceptable even if a fancier counter style isn’t applied.
The takeaway
CSS counters give you automatic, live numbering driven entirely by the cascade: counter-reset starts a counter, counter-increment advances it, and counter()/counters() render it — with counters() handling nested, multi-level numbering that would otherwise require JavaScript or generated markup. They’re a good fit for figure captions, outline numbering, and custom markers, as long as you back up any meaningful number with real accessible text rather than relying on generated content alone.
Tagged
Keep reading
Takina · · 4 min read CSS object-fit and object-position, Explained
object-fit controls how an image or video is cropped inside its box, and object-position controls which part of it stays visible. How they work together.
Takina · · 5 min read CSS inherit, initial, unset & revert Explained
CSS's four global keywords control where a property's value comes from. How inherit, initial, unset, and revert differ, with a comparison table.
Takina · · 4 min read Dynamic Viewport Units: dvh, svh, and lvh Explained
dvh, svh, and lvh fix the classic mobile vh bug where browser toolbars cut off full-height layouts. Here's what each unit measures and when to use it.