Counter in JavaScript: Build One Properly

Aug 13, 2026·20 min read

Search for a JavaScript counter and every result gives you the same twelve lines: a global count, a click handler, ++. Those lines work. They also break the moment you want two counters on the page, format the number with a comma, or ask what a screen reader announces when the digit changes.

So build that version first, run it, and then take it apart.

The counter, complete

Here is a complete, working counter. Save it as counter.html and open it in a browser.

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Counter</title>
  <style>
    .counter { display: flex; align-items: center; gap: 0.5rem; font: 1.25rem system-ui; }
    .counter output { min-width: 4ch; text-align: right; font-variant-numeric: tabular-nums; }
    .counter button { font: inherit; padding: 0.25rem 0.75rem; cursor: pointer; }
  </style>
</head>
<body>
  <div class="counter">
    <button type="button" id="dec">Subtract 1</button>
    <output id="display">0</output>
    <button type="button" id="inc">Add 1</button>
    <button type="button" id="reset">Reset</button>
  </div>

  <script>
    let count = 0;
    const display = document.getElementById('display');

    document.getElementById('inc').addEventListener('click', () => {
      count += 1;
      display.textContent = count;
    });

    document.getElementById('dec').addEventListener('click', () => {
      count -= 1;
      display.textContent = count;
    });

    document.getElementById('reset').addEventListener('click', () => {
      count = 0;
      display.textContent = count;
    });
  </script>
</body>
</html>

Four things are happening.

let count = 0 declares the number. document.getElementById('display') finds the element that shows it, once, and stores the reference so the browser is not asked to search the document on every click. addEventListener('click', …) registers a function to run each time that button is pressed. display.textContent = count writes the number into the element, converting it to a string on the way.

textContent and not innerHTML: the value is text, and textContent sets it as text. innerHTML would ask the browser to parse it as markup, which, the moment any part of that string comes from a user, is a way to inject HTML you did not intend.

type="button" on each <button> matters more than it looks. A <button> with no type is a submit button by default; put one inside a <form> and clicking it submits and reloads the page, which resets your counter to zero and looks like a mysterious bug.

font-variant-numeric: tabular-nums in the CSS makes every digit the same width, so the number stops jittering sideways as it counts past 9.

That is the counter every tutorial ships. Everything below fixes something in it.

Where the state actually lives

One rule runs through the rest of this article:

The counter above already obeys the rule, almost by accident. The version that breaks looks like this, and it is common enough that it is worth naming:

// the anti-pattern: reading state back out of the page
inc.addEventListener('click', () => {
  const current = parseInt(display.textContent, 10);
  display.textContent = current + 1;
});

That appears to work, because at that moment the element contains the digits 0. The page is being used as the storage. And a page is a bad place to store a number, because a page is a place where you eventually put words:

console.log(parseInt('1,000', 10));
console.log(parseInt('Count: 3', 10));
console.log(Number('1,000'));
1
NaN
NaN

parseInt reads digits from the front of the string and stops at the first character it cannot use, so a thousands separator silently turns one thousand into one. Number is stricter and gives up entirely, producing NaN. parseInt also returns NaN when there is nothing numeric to find at all. Add NaN to anything and you get NaN, so the display reads NaN from that click onwards and no further clicking will rescue it. The bug arrives on the day you add formatting, weeks after the code was written.

The fix is one function. Give the counter a single place that turns state into pixels, and call it whenever the state changes:

let count = 0;
const display = document.getElementById('display');

function render() {
  display.textContent = count;
}

document.getElementById('inc').addEventListener('click', () => {
  count += 1;
  render();
});

Now there is exactly one line in the program that writes to the display, and zero lines that read from it. Data goes one way: variable, then screen. That one-way rule is not a JavaScript thing, incidentally. It is the same constraint React’s one-way data flow is built around, and you can have it here for the price of naming a function.

State stored in the pagedisplaytextContentparseInt(text)+ 1readwriteone comma in the text and it is NaNState in a variablelet countrender()displaynothing reads the DOM back
The anti-pattern is a cycle through the DOM; the fix is a one-way line from variable to screen.

Why your counter is a global, and how closures fix it

let count = 0 at the top level of a <script> is a global. Not on window, exactly, since let and const create bindings in a separate global lexical scope rather than properties of window, but global in the way that matters: every other script on the page shares the same name. With var or an implicit global, the last declaration wins and silently clobbers the first. With let or const, the second declaration is a SyntaxError that takes down that whole script. Either way the name is shared and unprotected.

There is a second trap sitting next to it, and it is why some broken tutorial code appears to work. Per the HTML specification, every element with a non-empty id is exposed as a named property on the window object. Give your display id="count" and window.count already resolves to that element, before you have declared anything. Code that forgot its var count = 0 does not crash with a ReferenceError; it quietly starts doing arithmetic on an HTML element. That is the class of bug that survives review because the reviewer sees a name that obviously exists.

The fix is a factory function that closes over its own variable:

function createCounter(start = 0) {
  let count = start;

  return {
    increment() { count += 1; return count; },
    decrement() { count -= 1; return count; },
    reset()     { count = start; return count; },
    get value() { return count; }
  };
}

const clicks = createCounter();
const lives = createCounter(3);

clicks.increment();
clicks.increment();
lives.decrement();

console.log(clicks.value, lives.value);
console.log(clicks.count);
console.log(Object.keys(clicks).join(', '));
2 2
undefined
increment, decrement, reset, value

count is declared inside createCounter, so it disappears when the function returns. Except it does not, because the three methods still refer to it, and a function that refers to a variable from an enclosing scope keeps that variable alive. That is a closure, and it is the whole mechanism here.

Look at what the output proves. clicks.count is undefined: there is no property called count on the returned object, and no expression anywhere outside createCounter can reach the binding. Object.keys lists the four things you chose to expose and nothing else. The number is private in the only sense that matters, which is that nothing can change it except through the three methods you wrote.

And lives started at 3 while clicks started at 0. Every call to createCounter runs the function body again, creating a fresh count. Two counters, zero shared state, one function. How the engine actually keeps those bindings alive after the function returns, and what that costs in memory, is the subject of Part 9: Under the Hood.

createCounter()each call: new bindingclickscount = 2increment()decrement()reset()get valuelivescount = 2increment()decrement()reset()get valuesame value, separate bindingsclicks.count is undefined
Each call to createCounter builds a fresh scope; the count inside it is reachable only through the returned methods.

get value() is a getter: reading clicks.value runs a function that returns the current number. It is read-only from outside, so clicks.value = 500 does nothing useful, which is the point.

Several counters on one page

With the factory in place, N counters need no more code than one. Mark up each counter with data-* attributes and let a single listener on the container handle all of them:

<div class="counters">
  <div class="counter" data-name="coffee" data-start="0">
    <span>Coffees</span>
    <button type="button" data-action="decrement">Subtract 1</button>
    <output class="value">0</output>
    <button type="button" data-action="increment">Add 1</button>
  </div>

  <div class="counter" data-name="tabs" data-start="12">
    <span>Open tabs</span>
    <button type="button" data-action="decrement">Subtract 1</button>
    <output class="value">12</output>
    <button type="button" data-action="increment">Add 1</button>
  </div>
</div>

<script>
  const counters = new Map();

  for (const root of document.querySelectorAll('.counter')) {
    const counter = createCounter({ start: Number(root.dataset.start) || 0 });
    const display = root.querySelector('.value');
    counters.set(root, { counter, display });
    display.textContent = counter.value;
  }

  document.querySelector('.counters').addEventListener('click', (event) => {
    const button = event.target.closest('button[data-action]');
    if (!button) return;

    const root = button.closest('.counter');
    const entry = counters.get(root);
    entry.counter[button.dataset.action]();
    entry.display.textContent = entry.counter.value;
  });
</script>

One listener on .counters catches clicks from every button inside it, because a click on a button bubbles up to its ancestors. event.target is whatever was clicked, and closest('button[data-action]') walks up from there to find the nearest button carrying an action, returning null if the click landed on the label or the gap between buttons. That guard is the whole of the delegation pattern.

button.dataset.action is the string "increment" or "decrement", and entry.counter[…]() calls the method of that name. The two allowed values come from your own markup, not from user input.

Note what data-start is and is not. It is configuration, read once at construction time to seed the counter. It is not state, and after that first read nothing in the program consults the page again. The Map is keyed by the element, so the association between a DOM node and its private counter lives in JavaScript too.

Adding a third counter is now three lines of HTML.

Limits, steps and formatting

A counter that goes negative when it counts physical things is the classic beginner bug. Clamp it inside the factory, where the rule belongs, rather than in the click handler where you will forget one of the branches:

function createCounter({ start = 0, min = -Infinity, max = Infinity, step = 1 } = {}) {
  const clamp = (n) => Math.min(max, Math.max(min, n));
  let count = clamp(start);

  return {
    increment() { count = clamp(count + step); return count; },
    decrement() { count = clamp(count - step); return count; },
    reset()     { count = clamp(start); return count; },
    get value()  { return count; },
    get atMin()  { return count <= min; },
    get atMax()  { return count >= max; }
  };
}

const guests = createCounter({ start: 0, min: 0, max: 4, step: 2 });

guests.decrement();
console.log(guests.value, guests.atMin);

guests.increment();
guests.increment();
guests.increment();
console.log(guests.value, guests.atMax);
0 true
4 true

Math.max(min, n) pulls anything below the floor up to it and Math.min(max, …) pushes anything above the ceiling down, so one clamp handles both ends and both directions. atMin and atMax exist so the render step can disable the button that no longer does anything:

function render() {
  display.textContent = guests.value;
  decButton.disabled = guests.atMin;
  incButton.disabled = guests.atMax;
}

A disabled button is not just cosmetic. It stops being focusable and assistive technology reports it as unavailable, so the limit is communicated rather than merely enforced.

There is a ceiling you did not choose, too:

console.log(Number.MAX_SAFE_INTEGER);
console.log(Number.MAX_SAFE_INTEGER + 1 === Number.MAX_SAFE_INTEGER + 2);
9007199254740991
true

Past 2^53 - 1, integers can no longer be represented exactly, and two different numbers compare as equal. No hand-clicked counter will get there, but a counter driven by incoming events might, and Numbers explains why. BigInt is the answer if you genuinely need it.

Formatting belongs at render time and nowhere else:

const fmt = new Intl.NumberFormat('en-US');
console.log(fmt.format(1000));
console.log(fmt.format(1234567));
1,000
1,234,567

Build the formatter once, outside render, and call fmt.format(counter.value) inside it. The variable stays a Number forever; only the string handed to textContent has commas in it. This is exactly the case that breaks the parseInt(display.textContent) version from the second section, and it costs nothing here because nothing ever reads that string back.

Making it accessible

Two decisions cover most of it.

Use real <button> elements. A <div onclick> is not reachable by keyboard, does not respond to Enter or Space, and is announced as nothing in particular. A <button> gets all of that for free, and getting it back on a div takes tabindex, role="button" and a keydown handler that you will write slightly wrong.

Then announce the change. When the digit inside a plain <span> updates, focus has not moved and nothing has been navigated, so a screen reader user pressing the button hears silence. The number changed on screen only.

Two ways to fix it. The <output> element used throughout this article has an implicit ARIA role of status, and many browsers implement it as a live region, so updated content is announced without focus moving to it. Or say it explicitly on whatever element you are using:

<div class="counter">
  <button type="button" id="dec">Subtract 1</button>
  <label for="display">Current count</label>
  <output id="display" aria-live="polite">0</output>
  <button type="button" id="inc">Add 1</button>
</div>

aria-live="polite" announces the update at the next graceful opportunity, without cutting off whatever the user is currently listening to. aria-live="assertive" interrupts immediately, and should be kept for time-sensitive things: a session about to expire, an error that stops the task. A shopping quantity going from 2 to 3 is not that. Reach for assertive on a counter and you produce something that talks over the user every time they press a button.

Support for both <output> and aria-live varies between screen readers, so if the announcement matters to your product, test it with the ones your users actually run rather than trusting the attribute.

Two smaller things. Label the buttons with words, not bare symbols: a button reading + may be announced as “plus” or as nothing at all, while aria-label="Add one guest" is unambiguous. And keep the live region in the DOM from the start rather than creating it at the moment of the update, since a region that appears and announces in the same tick is unreliable.

Remembering the count after a reload

localStorage persists across reloads and across browser restarts. It stores strings only, and that constraint is the whole of the difficulty:

console.log(Number(null));
console.log(Number('7'), typeof Number('7'));
console.log(Number(''));
console.log(Number('abc'));
0
7 number
0
NaN

getItem returns null when the key does not exist, and Number(null) is 0, which happens to be the right answer for a fresh counter and hides the case from you. Number('abc') is NaN, which is what you get after a user edits their storage or after you change the key’s format. Guard for it:

const KEY = 'counter:coffee';

function load(fallback = 0) {
  const raw = localStorage.getItem(KEY);
  if (raw === null) return fallback;
  const stored = Number(raw);
  return Number.isFinite(stored) ? stored : fallback;
}

const coffee = createCounter({ start: load(0), min: 0 });
const display = document.getElementById('display');

function render() {
  display.textContent = coffee.value;
  localStorage.setItem(KEY, String(coffee.value));
}

render();

document.getElementById('inc').addEventListener('click', () => {
  coffee.increment();
  render();
});

Number.isFinite rejects NaN and also Infinity, so a corrupt entry falls back to zero instead of poisoning every later increment. String(coffee.value) is explicit about the conversion that setItem would do anyway; write it, because the day the value is an object is the day you find out setItem stringifies it to [object Object].

Prefix the key with something specific to your app. localStorage is shared by every script on the origin, and "count" is a name someone else will also pick.

The initial load is still one-way: storage seeds the variable at startup, and after that the variable is the truth and storage is a second rendering target alongside the display. Nothing reads it back mid-session.

Two limits worth stating plainly. Storage is per-origin and per-browser, so the count is on that machine in that browser: it starts empty in a private window and is discarded when that session ends, it is absent on the user’s phone, and it disappears when they clear site data. If the number needs to be the same number for the same person everywhere, it belongs on a server. And setItem can throw when the storage quota is exceeded or when a browser blocks storage entirely, so a counter that must not break on failure wants a try/catch around the write.

The other two counters: CSS and count-up animations

“Counter” means two other things on the web, and readers arrive at this query wanting either.

CSS counters: numbering without JavaScript

CSS has its own counting mechanism, used for numbering sections, figures and list items. counter-reset initialises a named counter, to 0 unless you say otherwise. counter-increment changes it by 1 unless another value is given. counter() returns the current value for use in the content property:

article {
  counter-reset: section;
}

article h2 {
  counter-increment: section;
}

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

Add a heading, remove a heading, reorder them: the numbering rewrites itself with no script running at all. For numbered figures, chapter headings and nested outlines, this is the right tool and JavaScript is the wrong one.

It is also the reason MDN’s CSS page ranks on this search, and it cannot do what the rest of this article is about. The value exists only in the rendering. There is no DOM property holding it, no way to read it back from a script, and no event when it changes. It cannot respond to a click, cannot be saved, cannot be checked against a maximum. CSS counters are for numbering; the JavaScript counter above is for counting.

Animating a count-up to a target

The other one: a number that races from 0 to 250 when a statistic scrolls into view.

The tempting version is setInterval adding a fixed amount every tick. It drifts. setInterval’s delay is a minimum, not a promise, so a busy main thread stretches the gaps and the animation finishes late by an amount you cannot predict, having also produced a different number of frames on a 60Hz display than on a 144Hz one.

Drive it from elapsed time instead. requestAnimationFrame invokes your callback with a single DOMHighResTimeStamp argument, the end time of the previous frame’s rendering in milliseconds since the time origin, and calls it at a rate that generally matches the display refresh rate, most commonly 60Hz but also 75Hz, 120Hz and 144Hz. Use that timestamp to ask what the value should be now, rather than adding a step and hoping:

function valueAt(elapsed, duration, from, to) {
  const t = Math.min(elapsed / duration, 1);
  return Math.round(from + (to - from) * t);
}

console.log(valueAt(0, 1000, 0, 250));
console.log(valueAt(500, 1000, 0, 250));
console.log(valueAt(1400, 1000, 0, 250));
0
125
250

t is progress from 0 to 1, clamped so a late frame cannot overshoot the target. At half the duration you are at half the range, whatever happened to the frame rate in between. Wire it up:

Two ways to reach 2502500durationfrom elapsed timefixed step per tickelapsed time
Fixed step per tick drifts past the duration; a value computed from elapsed time arrives on time.
function countUp(display, to, duration = 1000) {
  const from = 0;
  let startTime = null;

  function frame(now) {
    if (startTime === null) startTime = now;
    const elapsed = now - startTime;

    display.textContent = valueAt(elapsed, duration, from, to);

    if (elapsed < duration) requestAnimationFrame(frame);
  }

  requestAnimationFrame(frame);
}

countUp(document.getElementById('display'), 250, 1000);

The first frame’s timestamp becomes the origin, every later frame subtracts it, and the animation ends when elapsed time reaches the duration rather than after some count of ticks. The last frame always writes exactly to, because t is clamped to 1.

One courtesy: this is decoration, and users who have asked their system to reduce motion should get the number rather than the race. window.matchMedia('(prefers-reduced-motion: reduce)').matches tells you, and the honest response is to set display.textContent = to and skip the animation entirely.

Note that this is animation, not counting. It has no state to protect, no user input, and nothing to persist. When the count comes from a button, go back to the factory.

Frequently asked questions

How do you make a counter in JavaScript?
Keep the number in a JavaScript variable, attach a click listener to a button that changes the variable, and write the new value into an element with textContent. Three lines of logic and one line of rendering. The variable is the counter; the element on the page is only a picture of it.
Why does my counter show NaN?
Almost always because the code reads the number back out of the page with parseInt(display.textContent) instead of keeping it in a variable. As soon as the display holds anything other than bare digits, like '1,000' or 'Count: 3', the parse fails or truncates and every later increment builds on garbage. Keep the value in JavaScript and never read it back from the DOM.
How do I put two counters on the same page?
Write a factory function that returns an object with increment, decrement and reset methods over a variable declared inside it. Each call produces an independent private count, so createCounter() twice gives you two counters that cannot touch each other's state. Then wire the buttons with one delegated click listener on a shared container.
How do I make a counter remember its value after a page reload?
Save on every change with localStorage.setItem('count', String(counter.value)) and read it back at startup. getItem returns null when the key is missing and always returns a string otherwise, so convert with Number() and guard against NaN before using it. Storage is per-origin and per-browser, so the count follows the browser, not the user.