CSS counters: automatic numbers for anything on the page

A CSS counter is a number the browser keeps while it lays out the page. You create it, add to it on the elements you choose, and print it in a pseudo-element.

A CSS counter is a number that the browser tracks while it lays out the page. counter-reset creates it, counter-increment adds to it on each element you pick, and counter() prints the current value in a pseudo-element's content. No number is written in the HTML.

Change the values below and watch the list renumber. The CSS on the left is exactly what the list uses.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>CSS counter lab</title>
<style>
  body { margin: 0; padding: 14px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  .wrap { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
  @media (max-width: 560px) { .wrap { grid-template-columns: 1fr; } }
  .panel { background: #fff; border-radius: 12px; padding: 12px 14px; box-shadow: 0 2px 8px rgba(0, 0, 0, .06); }
  label { display: flex; align-items: center; justify-content: space-between; gap: 8px; font-size: 13px; margin: 6px 0; }
  input[type=number] { width: 64px; font: inherit; padding: 3px 6px; }
  select { font: inherit; padding: 3px; }
  pre { margin: 10px 0 0; padding: 10px; background: #1d2330; color: #e6edf3; border-radius: 8px;
        font: 12px/1.5 ui-monospace, Consolas, monospace; white-space: pre-wrap; }
  .items p { margin: 6px 0; padding: 7px 10px; background: #f4f5f7; border-radius: 8px; font-size: 14px; }
  .items p::before { font-weight: 700; color: #1d4ed8; }
</style>
<style id="live"></style>
</head>
<body>
<div class="wrap">
  <div class="panel">
    <label>counter-reset start <input type="number" id="start" value="0"></label>
    <label>counter-increment step <input type="number" id="step" value="1"></label>
    <label>counter-set on item 4 <input type="number" id="jump" placeholder="off"></label>
    <label>Style
      <select id="style">
        <option>decimal</option>
        <option>decimal-leading-zero</option>
        <option>upper-roman</option>
        <option>lower-alpha</option>
        <option>lower-greek</option>
      </select>
    </label>
    <label>Hide item 3 with display: none <input type="checkbox" id="hide"></label>
    <pre id="code"></pre>
  </div>
  <div class="panel items" id="items">
    <p>Plan</p><p>Draft</p><p>Review</p><p>Edit</p><p>Publish</p><p>Share</p>
  </div>
</div>

<script>
  const $ = (id) => document.getElementById(id);

  // Build the CSS from the controls, apply it, and show the same text.
  function update() {
    const jump = $('jump').value;
    let css =
`.items { counter-reset: item ${$('start').value || 0}; }
.items p { counter-increment: item ${$('step').value || 0}; }
.items p::before {
  content: counter(item, ${$('style').value}) ". ";
}`;
    if (jump !== '') css += `\n.items p:nth-child(4) { counter-set: item ${jump}; }`;
    if ($('hide').checked) css += `\n.items p:nth-child(3) { display: none; }`;
    $('live').textContent = css;
    $('code').textContent = css;
  }

  document.querySelectorAll('input, select').forEach((el) => el.addEventListener('input', update));
  update();
</script>
</body>
</html>
Try a start value of 4, a step of 2, a jump on item 4, or hide item 3. The CSS shown is the CSS applied.

Counters are often met on ordered lists. The ol tag guide covers list numbering, start and nested 1.1 lists. This guide uses counters on everything else: headings, figures, tables and form inputs.

The three properties and one function

Every counter goes through the same three moments: it is created, it grows, and it is printed.

counter-reset creates the counter, counter-increment adds to it on each item, counter() prints it.
counter-reset creates the counter, counter-increment adds to it on each item, counter() prints it.
  1. Create it: counter-reset: item on the container.
  2. Add to it: counter-increment: item on each item.
  3. Print it: content: counter(item) in ::before or ::after.
  4. Restart sub-counters on each parent heading for 1.1 numbers.
.steps { counter-reset: item; }
.steps p { counter-increment: item; }
.steps p::before { content: counter(item) ". "; }
Property What it does Example
counter-reset Creates a counter, 0 by default counter-reset: item 4
counter-increment Adds to it, 1 by default counter-increment: item 2
counter-set Sets a counter to a value, mid-count counter-set: item 10
counter() Prints the value counter(item, upper-roman)

The number after the name is the value that counter-reset stores. counter-reset: item 4 stores 4, so the first item shows 5. A negative step such as counter-increment: item -1 counts down.

When one element has both counter-increment and counter-set, the set happens last. That is why item 4 in the lab shows exactly the value you type, and item 5 continues from it.

Number styles: 01, Roman numerals and letters

The second argument of counter() picks how the number is drawn. It takes the same names as list-style-type.

h2::before { content: counter(chapter, decimal-leading-zero) " "; }  /* 01 02 03 */
.part::before { content: "Part " counter(part, upper-roman); }        /* Part IV */
Style 1, 2, 4 look like
decimal 1, 2, 4
decimal-leading-zero 01, 02, 04
upper-roman I, II, IV
lower-alpha a, b, d
lower-greek α, β, δ

decimal-leading-zero pads to two digits. Values from 10 on print as they are.

Numbering headings: 1, 1.1, 1.2

Headings in an article are siblings, not nested lists. Use one counter per level and print both with counter():

article { counter-reset: h2; }
h2 { counter-increment: h2; counter-reset: h3; }  /* each h2 restarts its h3s */
h2::before { content: counter(h2) ". "; }
h3 { counter-increment: h3; }
h3::before { content: counter(h2) "." counter(h3) " "; }

The line that matters is counter-reset: h3 on the h2. A reset applies to the element and to the siblings that follow it, so every h2 starts a new count for the h3 headings under it.

Reset h3 once on body and it runs on through the page. Reset it on each h2 and every section starts at .1.
Reset h3 once on body and it runs on through the page. Reset it on each h2 and every section starts at .1.

counters(), with an s, is not the tool here. It joins counters that are nested inside each other, as in lists within lists. Flat headings have no nesting to join, so it cannot build 2.1 from them.

Figures and tables: "Figure 3"

Figures and tables get their own counters, separate from the headings. Increment on the element and print in its caption:

article { counter-reset: fig tbl; }
figure { counter-increment: fig; }
figcaption::before { content: "Figure " counter(fig) ". "; }
table { counter-increment: tbl; }
caption::before { content: "Table " counter(tbl) ". "; }

Insert a section at the top below. Every heading and figure after it renumbers, and the script only adds elements. The figure tag guide covers the <figure> element itself.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Numbered headings and figures with CSS counters</title>
<style>
  body { margin: 0; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  .bar { position: sticky; top: 0; display: flex; gap: 8px; flex-wrap: wrap; padding: 10px 14px; background: #fff; border-bottom: 1px solid #e1e4ea; }
  button { font: inherit; font-size: 13px; padding: 6px 10px; border-radius: 8px; border: 1px solid #c9cdd4; background: #fff; cursor: pointer; }
  .doc { max-width: 620px; margin: 0 auto; padding: 4px 16px 16px; }
  h2 { font-size: 18px; margin: 18px 0 6px; }
  h3 { font-size: 15px; margin: 12px 0 4px; }
  p { margin: 4px 0; font-size: 14px; line-height: 1.5; }
  figure { margin: 10px 0; }
  .pic { height: 46px; border-radius: 8px; background: linear-gradient(90deg, #93c5fd, #c4b5fd); }
  figcaption, caption { font-size: 13px; color: #5b6270; margin-top: 4px; text-align: left; }
  table { border-collapse: collapse; font-size: 13px; margin: 10px 0; }
  td, th { border: 1px solid #d5d9e0; padding: 4px 8px; }
  .new { background: #fff3c4; }  /* highlights the inserted part */

  /* The numbering. No number is written in the HTML. */
  .doc { counter-reset: h2 fig tbl; }
  .doc h2 { counter-increment: h2; counter-reset: h3; }  /* each h2 restarts its h3s */
  .doc h2::before { content: counter(h2) ". "; }
  .doc h3 { counter-increment: h3; }
  .doc h3::before { content: counter(h2) "." counter(h3) " "; }
  .doc figure { counter-increment: fig; }
  .doc figcaption::before { content: "Figure " counter(fig) ". "; font-weight: 700; }
  .doc table { counter-increment: tbl; }
  .doc caption::before { content: "Table " counter(tbl) ". "; font-weight: 700; }
</style>
</head>
<body>
<div class="bar">
  <button id="add">Insert a section at the top</button>
  <button id="remove">Remove it</button>
</div>
<div class="doc" id="doc">
  <h2>Setup</h2>
  <p>Install the tools you need.</p>
  <h3>Requirements</h3>
  <figure><div class="pic"></div><figcaption>The setup screen</figcaption></figure>
  <h3>Install</h3>
  <h2>Usage</h2>
  <h3>First run</h3>
  <table><caption>Command options</caption>
    <tr><th>Option</th><th>Effect</th></tr>
    <tr><td>--watch</td><td>Rebuild on save</td></tr>
  </table>
  <h3>Settings</h3>
  <figure><div class="pic"></div><figcaption>The settings panel</figcaption></figure>
  <h2>Help</h2>
  <p>Where to ask questions.</p>
</div>

<script>
  // The script only adds or removes elements. CSS renumbers everything.
  const doc = document.getElementById('doc');
  document.getElementById('add').addEventListener('click', () => {
    if (doc.querySelector('.new')) return;
    doc.insertAdjacentHTML('afterbegin',
      '<h2 class="new">Overview</h2><h3 class="new">Who it is for</h3>' +
      '<figure class="new"><div class="pic"></div><figcaption>The big picture</figcaption></figure>');
  });
  document.getElementById('remove').addEventListener('click', () => {
    doc.querySelectorAll('.new').forEach((el) => el.remove());
  });
</script>
</body>
</html>
The script inserts or removes elements. CSS numbers the headings, figures and table.

A counter cannot write "see Figure 3" elsewhere in the text. The value exists only where it is printed, so cross-references need a few lines of JavaScript or a number written by hand.

Counting checked boxes with CSS only

A checkbox that is ticked matches :checked, and a counter can be incremented from any selector. Put the two together and CSS keeps a tally:

.todo { counter-reset: total done; }
.todo input { counter-increment: total; }
.todo input:checked { counter-increment: total done; }
.todo .result::after { content: counter(done) " of " counter(total) " done"; }

Two details make it work. First, the :checked rule names both counters. A second counter-increment replaces the first one, like any CSS property, so counter-increment: done alone would stop counting the total.

Second, the result element comes after the inputs in the HTML. Counters follow source order, so a result placed first would read 0. The demo gives it order: -1 inside a flex column to draw it on top anyway.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>CSS-only checklist and quiz score</title>
<style>
  body { margin: 0; padding: 12px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  .wrap { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; align-items: start; }
  @media (max-width: 560px) { .wrap { grid-template-columns: 1fr; } }
  .card { background: #fff; border-radius: 12px; padding: 10px 14px 12px; box-shadow: 0 2px 8px rgba(0, 0, 0, .06);
          display: flex; flex-direction: column; }
  h2 { font-size: 15px; margin: 2px 0 6px; }
  label { display: flex; gap: 8px; align-items: center; font-size: 14px; padding: 4px 0; cursor: pointer; }
  input { width: 17px; height: 17px; margin: 0; accent-color: #16a34a; }
  input:checked + span { color: #6b7280; text-decoration: line-through; }
  .quiz input:checked + span { text-decoration: none; color: inherit; font-weight: 600; }
  .result { order: -1; margin: 0 0 6px; padding: 6px 10px; border-radius: 8px; background: #eef7f1; color: #0f5132; font-weight: 700; font-size: 14px; }

  /* 1. Checklist: count every box, and the checked ones. */
  .todo { counter-reset: total done; }
  .todo input { counter-increment: total; }
  .todo input:checked { counter-increment: total done; }  /* list both: this line replaces the one above */
  .todo .result::after { content: counter(done) " of " counter(total) " done"; }

  /* 2. Quiz: a custom counter style draws circled numbers. */
  @counter-style circled {
    system: fixed;
    symbols: "\2460" "\2461" "\2462" "\2463" "\2464";  /* 1 to 5 in circles */
  }
  .quiz { counter-reset: q score answered; }
  .quiz fieldset { counter-increment: q; border: 0; margin: 0 0 6px; padding: 0; }
  .quiz legend { font-size: 14px; font-weight: 600; padding: 0; margin-bottom: 2px; }
  .quiz legend::before { content: counter(q, circled) " "; color: #1d4ed8; }
  .quiz .opts { display: flex; flex-wrap: wrap; column-gap: 14px; }
  .quiz input:checked { counter-increment: answered; }
  .quiz input.right:checked { counter-increment: answered score; }
  .quiz .result::after { content: counter(score) " of " counter(q) " right, " counter(answered) " answered"; }
</style>
</head>
<body>
<div class="wrap">
  <form class="card todo">
    <h2>Launch checklist</h2>
    <label><input type="checkbox" checked><span>Write the page</span></label>
    <label><input type="checkbox" checked><span>Check it on a phone</span></label>
    <label><input type="checkbox"><span>Fix the broken links</span></label>
    <label><input type="checkbox"><span>Add a page title</span></label>
    <label><input type="checkbox"><span>Compress the images</span></label>
    <label><input type="checkbox"><span>Ask a friend to try it</span></label>
    <label><input type="checkbox"><span>Share the link</span></label>
    <!-- The result sits after the boxes in the HTML, so the counters are already final.
         order: -1 draws it at the top anyway. -->
    <p class="result"></p>
  </form>

  <form class="card quiz">
    <h2>Quick quiz</h2>
    <fieldset>
      <legend>Which property creates a counter?</legend>
      <div class="opts">
        <label><input type="radio" name="a"><span>counter-increment</span></label>
        <label><input type="radio" name="a" class="right"><span>counter-reset</span></label>
      </div>
    </fieldset>
    <fieldset>
      <legend>Which function prints 1.2.3?</legend>
      <div class="opts">
        <label><input type="radio" name="b"><span>counter()</span></label>
        <label><input type="radio" name="b" class="right"><span>counters()</span></label>
      </div>
    </fieldset>
    <fieldset>
      <legend>Does a display: none item count?</legend>
      <div class="opts">
        <label><input type="radio" name="c"><span>Yes</span></label>
        <label><input type="radio" name="c" class="right"><span>No</span></label>
      </div>
    </fieldset>
    <p class="result"></p>
  </form>
</div>
</body>
</html>
No script at all. Tick boxes or pick answers and the totals change. The question numbers come from an @counter-style.

The quiz uses the same idea with radio buttons: every checked answer adds to answered, and the right ones also add to score.

The right answers sit in the HTML as a class, so this suits practice checks, not graded tests. The checkbox guide covers the input itself.

Custom symbols with @counter-style

When the built-in styles are not enough, @counter-style defines a new one. The quiz above draws circled numbers:

@counter-style circled {
  system: fixed;
  symbols: "\2460" "\2461" "\2462" "\2463" "\2464";  /* circled 1 to 5 */
}
legend::before { content: counter(q, circled) " "; }

system decides how the symbols are used. fixed uses each symbol once. cyclic repeats them, which suits bullets. extends decimal copies an existing style so you can change parts of it.

A fixed style that runs out of symbols falls back to decimal, so a sixth question here would show a plain 6. Also, prefix and suffix apply when the style is a list marker, as in list-style: circled. counter() prints the symbol without them.

Counters are not part of the document

The number you see is drawn by the browser from CSS. It is not in the HTML text.

The screen shows "2. Usage". The element's text is still "Usage".
The screen shows "2. Usage". The element's text is still "Usage".
  • textContent and innerText return the heading without its number.
  • Selecting and copying the page usually leaves the numbers out.
  • Screen readers differ in how they handle generated content, so a number that carries meaning may not be announced.

Use counters for numbers that decorate or organise. If a number is part of the content, such as a clause number that people quote, write it in the HTML. The ::before and ::after guide has more on what generated content can and cannot do.

When it does not work

What you see Cause Fix
Nothing appears The pseudo-element has no content Add content: counter(name)
Sub-numbers never restart: 2.3, 2.4 The sub-counter is reset only once, on body Reset it on each parent heading
Numbering restarts inside each box No counter-reset on a shared ancestor, so each container starts its own Reset on the common parent
The first number is 2 counter-reset: name 1 Use counter-reset: name (starts at 0)
The first number is 0 The printing element never increments, for example the increment sits on ::after Increment on the element that prints
counters() shows 1.1 everywhere The items are siblings, not nested Use two counters, or nest and reset at every level
A number is skipped or the tally is low Elements with display: none do not increment Expected; visibility: hidden elements still count
A tally always reads 0 The result element comes before the inputs Move it after them; use order to place it visually

Numbered documents and CSS-only checklists are easier to try than to describe. A screenshot cannot be ticked, and an .html attachment may open as plain code on a phone.

To send the working page, paste it into a NOS document and choose Create share link. HTML to link walks through it.

The page renders as written and its scripts run, so the people you send it to can tick the boxes and insert sections themselves. If you change the code later, the same link shows the new version.

Questions people ask

How do I make a CSS counter show 01, 02, 03?

Pass a counter style as the second argument: content: counter(item, decimal-leading-zero). The same slot accepts upper-roman, lower-alpha, lower-greek and any style you define with @counter-style.

Why does my counter start at 2, or at 0?

counter-reset: item 1 sets the start value to 1, so the first increment makes it 2. Leave the number out to start from 0. A 0 on the first item means the element that prints the number never incremented it, for example because the increment sits on ::after or on a different element.

Can JavaScript read the value of a CSS counter?

Not directly. The number exists only in the rendered page. textContent and innerText return the element text without it. If a script needs the number, count the elements in JavaScript, or write the number into the HTML.

Can a CSS counter count checked checkboxes without JavaScript?

Yes. Give input:checked a counter-increment and print the counter in an element placed after the inputs. The counter follows source order, so the element that shows the total has to come after the boxes in the HTML. CSS order can still draw it above them.

What is the difference between counter() and counters()?

counter() prints the innermost counter of that name. counters() prints every nested counter of that name, joined by a string you pass, such as "1.2.3". counters() needs each nesting level to reset the counter, which is why it suits nested lists rather than flat h2 and h3 headings.

Keep reading