What are CSS counters?

CSS counters are a built-in way to generate automatic numbering in your UI—without writing numbers in your HTML. They’re perfect for things like step-by-step components, numbered headings, figure labels, and fancy lists.

The key idea: you define a counter, you reset it where you want numbering to start, and you increment it on each item. Then you print the number with content.

  • Great for: “Step 1, Step 2…”, “Figure 3”, “1.2.4” nested numbering, FAQ numbering, chapters/sections.
  • Not for: Doing math, storing user data, or replacing the semantic meaning of a real ordered list when you truly need one.

The 3 building blocks of CSS counters

CSS counters look like magic until you realize it’s always the same three pieces:

  1. counter-reset: creates (or resets) a counter to a value (usually 0).
  2. counter-increment: increments the counter each time an element appears.
  3. counter() / counters(): prints the counter value inside content.

We’ll start with the most common pattern: a counter on a parent, incremented on children, printed in a pseudo-element.

Your first CSS counter: numbering cards

In this first playground, we’ll number cards like “01, 02, 03…” using a counter named card.

.list {
  counter-reset: card;
}

.card::before {
counter-increment: card;
content: counter(card);
} 
.list {
  counter-reset: card;
}

.card::before {
  counter-increment: card;
  content: "0" counter(card);
}
  
.list {
  counter-reset: card;
}

.card::before {
  counter-increment: card;
  content: "Card " counter(card) " →";
}
  
*,
::before,
::after {
  box-sizing: border-box;
}

.wrap {
  max-width: 980px;
  padding: 18px;
  font-family: system-ui, Arial, sans-serif;
  display: grid;
  gap: 14px;
}

.note {
  border: 2px dashed #111;
  border-radius: 14px;
  padding: 12px;
  background: #fff;
}

.list {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 12px;
}

.card {
  border: 3px solid #111;
  border-radius: 16px;
  padding: 14px;
  background: #fff;
  box-shadow: 0 10px 0 #111;
  display: grid;
  gap: 8px;
  position: relative;
  min-height: 120px;
}

.card::before {
  position: absolute;
  top: 10px;
  right: 10px;
  border: 2px solid #111;
  border-radius: 999px;
  padding: 6px 10px;
  font-weight: 800;
  background: #f4f4f4;
}

.card h4 {
  margin: 0;
  font-size: 16px;
}

.card p {
  margin: 0;
  opacity: 0.85;
  line-height: 1.4;
}
  

This grid is numbered by a CSS counter. The numbers do not exist in the HTML. Click the snippets to switch how the number is printed.

Install

Get the thing, put it in place, pretend you meant to do it earlier.

Configure

Change settings until it “feels right” (a valid engineering technique).

Ship

Push to production and immediately watch the logs like a hawk.

Celebrate

Optional, but recommended. Even small wins count.

Iterate

Improve the parts you now realize you should’ve done yesterday.

Document

Future-you will be suspiciously grateful.

Here’s what happened:

  • The parent (.list) resets a counter named card to 0.
  • Each .card::before increments the counter (so the first card becomes 1).
  • The content property prints the current value with counter(card).

Starting values and step sizes

Counters don’t have to start at 1, and they don’t have to increment by 1. You can set a start value in counter-reset and a step size in counter-increment.

.list {
  counter-reset: item 0;
}

.item::before {
  counter-increment: item 1;
  content: counter(item) ".";
}
  
*,
::before,
::after {
  box-sizing: border-box;
}

.wrap {
  max-width: 980px;
  padding: 18px;
  font-family: system-ui, Arial, sans-serif;
  display: grid;
  gap: 14px;
}

.note {
  border: 2px dashed #111;
  border-radius: 14px;
  padding: 12px;
  background: #fff;
}

.list {
  display: grid;
  gap: 10px;
}

.item {
  border: 3px solid #111;
  border-radius: 14px;
  padding: 12px 14px 12px 56px;
  background: #fff;
  box-shadow: 0 10px 0 #111;
  position: relative;
}

.item::before {
  position: absolute;
  left: 12px;
  top: 50%;
  transform: translateY(-50%);
  width: 34px;
  height: 34px;
  border-radius: 10px;
  border: 2px solid #111;
  display: grid;
  place-items: center;
  font-weight: 900;
  background: #f4f4f4;
}

.item h4 {
  margin: 0;
  font-size: 16px;
}

.item p {
  margin: 6px 0 0 0;
  opacity: 0.85;
  line-height: 1.35;
}
  

Move the sliders. You can start at a different number, count by 2, count backwards, or even stay “stuck” at one value.

Choose a template

Pick a layout that matches the vibe you’re going for.

Add content

Headings, text, images—make it real.

Review

Fix the tiny things that will absolutely haunt you later.

Publish

Ship it. Then ship the improvements.

counter-reset with a number

counter-reset: item 0; means “create/reset the item counter and set it to 0.” The first increment makes it 1.

counter-increment with a number

counter-increment: item 1; means “increase item by 1 each time.” Use 2 to count 2, 4, 6… and use a negative number to count down.

Nested CSS counters for 1.2.3 style numbering

When you need chapter-style numbering like 2.4.1, you’ll typically use:

  • A counter for the top level (like section).
  • Another counter for the inner level (like sub).
  • The counters() function to print a chain of nested counter values separated by a string like ".".
.doc {
  counter-reset: section;
}

.section {
counter-increment: section;
counter-reset: sub;
}

.section > h3::before {
content: counter(section) ". ";
}

.sub {
counter-increment: sub;
}

.sub > h4::before {
content: counter(section) "." counter(sub) " ";
} 
.doc {
  counter-reset: section;
}

.section {
  counter-increment: section;
  counter-reset: sub;
}

.section > h3::before {
  content: "Section " counter(section) ": ";
}

.sub {
  counter-increment: sub;
}

.sub > h4::before {
  content: "§" counter(section) "." counter(sub) " ";
}
  
*,
::before,
::after {
  box-sizing: border-box;
}

.wrap {
  max-width: 980px;
  padding: 18px;
  font-family: system-ui, Arial, sans-serif;
  display: grid;
  gap: 14px;
}

.note {
  border: 2px dashed #111;
  border-radius: 14px;
  padding: 12px;
  background: #fff;
}

.doc {
  border: 3px solid #111;
  border-radius: 18px;
  background: #fff;
  box-shadow: 0 12px 0 #111;
  padding: 14px;
  display: grid;
  gap: 12px;
}

.section {
  border: 2px solid #111;
  border-radius: 14px;
  padding: 12px;
  background: #fafafa;
  display: grid;
  gap: 10px;
}

.section > h3,
.sub > h4 {
  margin: 0;
}

.section > h3 {
  font-size: 18px;
}

.sub {
  border: 2px dashed #111;
  border-radius: 12px;
  padding: 10px;
  background: #fff;
  display: grid;
  gap: 6px;
}

.sub p {
  margin: 0;
  opacity: 0.85;
  line-height: 1.35;
}
  

This is the classic “section + subsection” setup. Each section increments section and resets sub. Sub-items increment sub. Try the third snippet to see counters() in action.

Getting started

Install

Put the files where they need to go.

Run

Start it up and make sure it actually works.

Configuration

Defaults

Choose sensible defaults so users don’t hate you.

Overrides

Make it customizable without making it fragile.

The superpower pattern: reset inside the item

Notice this line:

counter-reset: sub; inside .section

That’s the “nested numbering” trick. Each time a new section appears, it resets the subsection counter back to 0, so the first subsection in each section becomes 1 again.

Real-world pattern: numbered headings

Let’s turn a list of headings into an auto-numbered mini article. This is especially nice for long tutorials or documentation pages.

.article {
  counter-reset: h2;
}

.article h2 {
counter-increment: h2;
counter-reset: h3;
}

.article h2::before {
content: counter(h2) ". ";
}

.article h3 {
counter-increment: h3;
}

.article h3::before {
content: counter(h2) "." counter(h3) " ";
} 
.article {
  counter-reset: h2;
}

.article h2 {
  counter-increment: h2;
  counter-reset: h3;
}

.article h2::before {
  content: "Chapter " counter(h2) ": ";
}

.article h3 {
  counter-increment: h3;
}

.article h3::before {
  content: "— " counter(h2) "." counter(h3) " ";
}
  
*,
::before,
::after {
  box-sizing: border-box;
}

.wrap {
  max-width: 980px;
  padding: 18px;
  font-family: system-ui, Arial, sans-serif;
}

.article {
  border: 3px solid #111;
  border-radius: 18px;
  background: #fff;
  box-shadow: 0 12px 0 #111;
  padding: 18px;
  display: grid;
  gap: 10px;
}

.article h2 {
  margin: 0;
  font-size: 22px;
}

.article h3 {
  margin: 10px 0 0 0;
  font-size: 16px;
}

.article p {
  margin: 6px 0 0 0;
  opacity: 0.88;
  line-height: 1.5;
}

.article h2::before,
.article h3::before {
  font-weight: 900;
}
  

Introduction

We’ll auto-number headings using counters, no manual updates required.

Why it helps

Reordering sections won’t break your numbering.

Where to use it

Docs, tutorials, course lessons, and anything that has a predictable hierarchy.

Implementation

The top level increments an h2 counter and resets the h3 counter.

Reset inside the parent

Each new h2 starts its own h3 sequence.

Print the numbers

Use content + counter() to display them.

Real-world pattern: a “Steps” component

A classic “Steps” UI uses counters for the numbering and pseudo-elements for the badge. The HTML stays clean: just a list of step items.

.steps {
  counter-reset: step;
}

.step::before {
counter-increment: step;
content: counter(step);
} 
.steps {
  counter-reset: step 4;
}

.step::before {
  counter-increment: step;
  content: counter(step);
}
  
.steps {
  counter-reset: step;
}

.step::before {
  counter-increment: step 2;
  content: counter(step);
}
  
*,
::before,
::after {
  box-sizing: border-box;
}

.wrap {
  max-width: 980px;
  padding: 18px;
  font-family: system-ui, Arial, sans-serif;
  display: grid;
  gap: 14px;
}

.note {
  border: 2px dashed #111;
  border-radius: 14px;
  padding: 12px;
  background: #fff;
}

.steps {
  border: 3px solid #111;
  border-radius: 18px;
  background: #fff;
  box-shadow: 0 12px 0 #111;
  padding: 16px;
  display: grid;
  gap: 12px;
}

.step {
  border: 2px solid #111;
  border-radius: 14px;
  padding: 14px 14px 14px 64px;
  background: #fafafa;
  position: relative;
  display: grid;
  gap: 6px;
}

.step::before {
  position: absolute;
  left: 14px;
  top: 14px;
  width: 38px;
  height: 38px;
  border-radius: 999px;
  border: 2px solid #111;
  background: #fff;
  display: grid;
  place-items: center;
  font-weight: 900;
}

.step h4 {
  margin: 0;
  font-size: 16px;
}

.step p {
  margin: 0;
  opacity: 0.85;
  line-height: 1.4;
}
  

Try the snippets: normal steps, starting at 5, and stepping by 2. This pattern is great for onboarding, checklists, and tutorials.

Create an account

Just enough info to get started. No 14-field forms, please.

Confirm your email

One click, done. Bonus points if you don’t hide the email in spam.

Pick your preferences

Let users choose, but don’t overwhelm them with options.

Start using the app

Get them to value quickly. That’s the whole game.

Real-world pattern: figure numbering with captions

Another super practical use: label images as Figure 1, Figure 2, etc. The figure order can change, and the numbers update automatically.

.gallery {
  counter-reset: figure;
}

.figure figcaption::before {
counter-increment: figure;
content: "Figure " counter(figure) ": ";
} 
.gallery {
  counter-reset: figure 9;
}

.figure figcaption::before {
  counter-increment: figure;
  content: "Figure " counter(figure) ": ";
}
  
*,
::before,
::after {
  box-sizing: border-box;
}

.wrap {
  max-width: 980px;
  padding: 18px;
  font-family: system-ui, Arial, sans-serif;
  display: grid;
  gap: 14px;
}

.gallery {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 12px;
}

.figure {
  border: 3px solid #111;
  border-radius: 18px;
  overflow: hidden;
  background: #fff;
  box-shadow: 0 12px 0 #111;
}

.figure .media {
  aspect-ratio: 16 / 9;
  background: url("https://picsum.photos/900/506") center / cover no-repeat;
}

.figure:nth-child(2) .media {
  background-image: url("https://picsum.photos/901/506");
}

.figure:nth-child(3) .media {
  background-image: url("https://picsum.photos/902/506");
}

.figure:nth-child(4) .media {
  background-image: url("https://picsum.photos/903/506");
}

.figure figcaption {
  padding: 12px 14px;
  border-top: 3px solid #111;
  line-height: 1.4;
}

.figure figcaption::before {
  font-weight: 900;
}
  

Custom list numbering with counters

Ordered lists (<ol>) already have numbering, but counters let you style and format that numbering in ways default list markers can’t. You can also use counters on an unordered list to create “numbered bullets.”

.fancy {
  counter-reset: li;
}

.fancy li::before {
counter-increment: li;
content: counter(li) ") ";
} 
.fancy {
  counter-reset: li;
}

.fancy li::before {
  counter-increment: li;
  content: "0" counter(li) " — ";
}
  
.fancy {
  counter-reset: li;
}

.fancy li::before {
  counter-increment: li;
  content: "[" counter(li) "] ";
}
  
*,
::before,
::after {
  box-sizing: border-box;
}

.wrap {
  max-width: 980px;
  padding: 18px;
  font-family: system-ui, Arial, sans-serif;
  display: grid;
  gap: 14px;
}

.panel {
  border: 3px solid #111;
  border-radius: 18px;
  background: #fff;
  box-shadow: 0 12px 0 #111;
  padding: 16px;
}

.fancy {
  margin: 0;
  padding: 0;
  list-style: none;
  display: grid;
  gap: 10px;
}

.fancy li {
  border: 2px solid #111;
  border-radius: 14px;
  padding: 12px 14px;
  background: #fafafa;
  line-height: 1.4;
}

.fancy li::before {
  font-weight: 900;
}
  
  • Plan the layout before you style it.
  • Keep components small and reusable.
  • Use spacing consistently across sections.
  • Test on small screens early, not “later”.

When you should prefer <ol>

If the order is meaningful (like “do these steps in sequence”), use a real <ol> in HTML. CSS counters are still useful for styling, but you want the semantic meaning of an ordered list.

Styling counters like badges and timelines

Counters really shine when you combine them with layout and pseudo-elements. Let’s build a mini “timeline” look: a line on the left and numbered nodes.

.timeline {
  counter-reset: t;
}

.event::before {
counter-increment: t;
content: counter(t);
} 
.timeline {
  counter-reset: t;
}

.event::before {
  counter-increment: t;
  content: "0" counter(t);
}
  
*,
::before,
::after {
  box-sizing: border-box;
}

.wrap {
  max-width: 980px;
  padding: 18px;
  font-family: system-ui, Arial, sans-serif;
}

.timeline {
  border: 3px solid #111;
  border-radius: 18px;
  background: #fff;
  box-shadow: 0 12px 0 #111;
  padding: 18px;
  display: grid;
  gap: 12px;
  position: relative;
}

.timeline::before {
  content: "";
  position: absolute;
  left: 34px;
  top: 18px;
  bottom: 18px;
  width: 4px;
  background: #111;
  border-radius: 999px;
}

.event {
  position: relative;
  padding: 14px 14px 14px 74px;
  border: 2px solid #111;
  border-radius: 14px;
  background: #fafafa;
  display: grid;
  gap: 6px;
}

.event::before {
  position: absolute;
  left: 16px;
  top: 14px;
  width: 36px;
  height: 36px;
  border-radius: 999px;
  border: 3px solid #111;
  background: #fff;
  display: grid;
  place-items: center;
  font-weight: 900;
}

.event h4 {
  margin: 0;
  font-size: 16px;
}

.event p {
  margin: 0;
  opacity: 0.85;
  line-height: 1.4;
}
  

Draft

Write the first version without judging it too hard.

Review

Fix the confusing parts and remove the “oops” moments.

Publish

Make it real. Then iterate based on feedback.

Maintain

Small improvements over time beat massive rewrites.

Common mistakes: “Why isn’t my CSS counter working?”

  • You forgot to reset the counter.

    If the parent never runs counter-reset, the counter might not exist (or might be inherited from somewhere unexpected). Add counter-reset: name; on a clear container.

  • You incremented the wrong element.

    The element with counter-increment is the one that “counts”. If you put it on the container instead of each item, you’ll only increment once.

  • Your pseudo-element doesn’t render.

    If you’re using ::before or ::after, it must have content (even if it’s empty). In counter examples, you almost always want content: counter(name);.

  • You reset too often.

    If you put counter-reset on each item, the counter restarts every time—so every item becomes “1”. Reset on the parent, increment on the children.

  • Nesting seems “off”.

    For nested numbering, remember the pattern: the parent increments its own counter and resets the child counter. That’s how you get “2.1, 2.2” inside section 2.

Accessibility and best practices

CSS counters usually appear via generated content (::before / ::after). That’s great visually, but it’s not always guaranteed to be announced the same way by assistive tech.

  • When order matters, use real HTML structure.

    For step-by-step instructions, prefer an <ol> and style it. Counters can enhance, but semantics should come first.

  • Don’t rely on the number alone.

    Pair numbers with clear headings or labels like “Step”, “Section”, “Figure”, so meaning doesn’t disappear if styles fail.

  • Keep counters predictable.

    Reset on obvious containers, name your counters clearly (step, figure, section), and avoid “mystery inheritance”.

Quick cheat sheet: CSS counters

  • Create/reset: counter-reset: name; or counter-reset: name 0;
  • Increment: counter-increment: name; or counter-increment: name 1;
  • Print: content: counter(name);
  • Nested print: content: counter(a) "." counter(b);
  • Deep nesting chain: content: counters(name, ".");

CSS counters conclusion

CSS counters are one of those features that feel like a party trick… until you use them in real components and never want to manually type “Step 17” again. Start simple: reset on the parent, increment on the items, print with content. Then level up with nested counters and richer styling.

As you saw in this tutorial, counters usually work best when combined with pseudo-elements. Learn more about pseudo-elements in the CSS ::before and ::after Pseudo-Elements Interactive Tutorial.