Build it
How to make a counter in HTML, CSS and JavaScript
A working counter needs three things: an element to display the number, two buttons, and a variable the buttons change. That is about twenty lines. Everything beyond it — step size, undo, saving between visits, keyboard shortcuts — is what separates a demo from something people actually use.
The minimum version
Three elements and one variable:
<output id="count">0</output>
<button id="dec" type="button">−</button>
<button id="inc" type="button">+</button>
<script>
let count = 0;
const out = document.getElementById("count");
const render = () => { out.textContent = count; };
document.getElementById("inc").onclick = () => { count++; render(); };
document.getElementById("dec").onclick = () => { count--; render(); };
</script>
Two details already matter. Use <output> rather than a <div>,
because it carries the right meaning for a calculated value. And give every button
type="button" — inside a form, a button without it defaults to
type="submit" and reloads the page on click, which is a genuinely confusing bug.
Make it accessible before you make it clever
A screen reader needs to be told the number changed:
<output id="count" aria-live="polite">0</output>
<button id="inc" type="button" aria-label="Increase count">+</button>
aria-live="polite" announces each new value. An aria-label matters
because "+" alone is announced as "plus" with no indication of what it does.
One caveat learned the hard way: if you later add automatic counting on a timer, suspend the live
region while it runs. Announcing four times a second is unusable — set
aria-live="off" for the duration and announce the total once at the end.
A step size, and the trap in it
const stepInput = document.getElementById("step");
function step() {
const n = Math.round(Number(stepInput.value));
return Number.isFinite(n) && n > 0 ? n : 1; // never trust the input
}
document.getElementById("inc").onclick = () => { count += step(); render(); };
Always validate. A number input can be empty, or contain e, or a pasted string;
Number("") is 0 and Number("abc") is NaN. Add
NaN to your count once and the display reads "NaN" until a reload, with no error in the
console to explain it.
Saving between visits
const KEY = "counter:value";
function save() {
try { localStorage.setItem(KEY, String(count)); } catch (e) { /* private mode */ }
}
function load() {
try {
const raw = localStorage.getItem(KEY);
if (raw !== null) count = Number(raw) || 0;
} catch (e) { /* storage blocked */ }
}
The try/catch is not optional. localStorage throws — not
returns null — in private windows and when a browser is set to block site data. An unguarded call
takes your whole script down at load, and the counter never appears at all.
Undo, which is what people actually want
The single most requested behaviour in any counter is taking back one accidental press. It is cheap: keep a stack of previous values.
const history = [];
function setCount(next) {
history.push(count);
if (history.length > 100) history.shift(); // bound the memory
count = next;
save(); render();
}
function undo() {
if (!history.length) return;
count = history.pop();
save(); render();
}
Bound the stack. Without the shift(), a long session grows an array forever.
Keyboard support
addEventListener("keydown", (e) => {
if (e.target.matches("input, textarea")) return; // let typing be typing
if (e.key === "ArrowUp" || e.key === "+") { e.preventDefault(); inc(); }
if (e.key === "ArrowDown" || e.key === "-") { e.preventDefault(); dec(); }
});
The first line is the one people forget. Without it, typing a step size fires the counter on every keystroke. And if you bind Space, exclude buttons and links — a focused button already activates on Space, so it would fire twice.
Mistakes worth avoiding
- Clamping at zero. Do not stop at zero unless the use case demands it. Scorekeeping and countdowns both need negatives, and it is a common complaint about otherwise-good counters.
- A reset button that fires on one tap. One stray touch destroys a long count. Make it press-and-hold, or require a second confirming tap.
- Rebuilding the whole list on every press. If you render several counters, patch the text of the one that changed. Wiping and recreating the DOM each tap resets scroll position and causes visible jank.
- Assuming a mouse. Add
touch-action: manipulationto buttons or mobile browsers add a ~300ms delay before the tap registers. - Forgetting the number gets long. A fixed font size that fits "0" will overflow at 1,000,000. Scale it down as the digit count grows.
Frequently asked questions
How do you create a counter in JavaScript?
Declare a variable at zero, get a reference to the element showing it, and attach click handlers to two buttons that add or subtract one and then rewrite the element's text. That is roughly twenty lines and needs no library or framework.
How do I make a counter that remembers its value?
Write the value to localStorage on every change and read it back on load. Wrap both in try/catch, because localStorage throws rather than returning null in private windows and when a browser blocks site data, which would otherwise break the whole script.
How do I add an undo button to a counter?
Push the previous value onto an array before each change, and pop from it to undo. Cap the array length, around 100 entries, so a long session cannot grow it without limit. This is the most requested counter feature and takes about ten lines.
Why does my counter show NaN?
Something non-numeric reached the arithmetic, almost always an empty or invalid number input: Number("") is 0 but Number("abc") is NaN, and NaN spreads through every later calculation. Validate with Number.isFinite before adding, and fall back to a sensible default.
How do I make a website hit counter?
That is a different thing and it needs a server, because the total must be shared between visitors. A browser-only counter stores its value per device. Shared totals require a backend or a hosted counting service to hold the number centrally.