A browser calculator is deceptively good practice. The layout teaches CSS Grid, the buttons teach event delegation, and the logic teaches you to think carefully about state — because a naive version breaks the moment someone presses . twice.
The HTML#
<main class="calc">
<output id="display">0</output>
<div class="keys" id="keys">
<button data-action="clear" class="span-2">AC</button>
<button data-action="delete">DEL</button>
<button data-op="/">÷</button>
<button data-num="7">7</button>
<button data-num="8">8</button>
<button data-num="9">9</button>
<button data-op="*">×</button>
<button data-num="4">4</button>
<button data-num="5">5</button>
<button data-num="6">6</button>
<button data-op="-">−</button>
<button data-num="1">1</button>
<button data-num="2">2</button>
<button data-num="3">3</button>
<button data-op="+">+</button>
<button data-num="0" class="span-2">0</button>
<button data-num=".">.</button>
<button data-action="equals">=</button>
</div>
</main>
<script src="calc.js" defer></script>Every button carries a data- attribute saying what it is. That single decision is what lets one listener handle all nineteen buttons.
The CSS#
.calc {
max-width: 320px;
margin: 40px auto;
padding: 16px;
border-radius: 16px;
background: #14161d;
font-family: system-ui, sans-serif;
}
#display {
display: block;
padding: 18px 14px;
margin-bottom: 12px;
border-radius: 10px;
background: #0c0e14;
color: #f2f4f9;
font-size: 2rem;
text-align: right;
overflow-x: auto;
white-space: nowrap;
}
.keys {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
}
.keys button {
padding: 16px 0;
border: 0;
border-radius: 10px;
background: #262c39;
color: #e5e8f0;
font-size: 1.1rem;
cursor: pointer;
}
.keys button:hover { background: #333a49; }
.keys button[data-op] { background: #4f46e5; color: #fff; }
.keys button[data-action="equals"] { background: #059669; color: #fff; }
.span-2 { grid-column: span 2; }grid-template-columns: repeat(4, 1fr) plus span-2 for the wide keys gives the whole layout in four lines. See CSS basics explained.
Step 1: the state#
A calculator has exactly three things to remember.
const state = {
current: "0", // what is on screen
previous: null, // the number waiting for an operator
operator: null, // the pending operation
};
const display = document.querySelector("#display");
function render() {
display.textContent = state.current;
}Same pattern as the to-do app: keep the truth in an object, and draw the screen from it.
Step 2: typing numbers#
function inputNumber(char) {
if (char === "." && state.current.includes(".")) return; // only one dot
if (state.current === "0" && char !== ".") {
state.current = char; // replace the leading zero
} else {
state.current += char;
}
}Those two guards are the difference between a calculator and a bug report. Without the first you get 3.4.5; without the second you get 07.
Step 3: the operators#
function calculate(a, b, operator) {
a = Number(a);
b = Number(b);
switch (operator) {
case "+": return a + b;
case "-": return a - b;
case "*": return a * b;
case "/": return b === 0 ? null : a / b; // null means "cannot"
default: return b;
}
}
function chooseOperator(op) {
if (state.operator && state.previous !== null) {
const result = calculate(state.previous, state.current, state.operator);
if (result === null) { showError(); return; }
state.current = String(result);
}
state.previous = state.current;
state.operator = op;
state.current = "0";
}Pressing an operator when one is already pending finishes the previous sum first. That is what makes 2 + 3 + 4 show 5 before you press the second plus, exactly like a real calculator.
Step 4: one listener for every button#
document.querySelector("#keys").addEventListener("click", (event) => {
const button = event.target.closest("button");
if (!button) return;
if (button.dataset.num) {
inputNumber(button.dataset.num);
} else if (button.dataset.op) {
chooseOperator(button.dataset.op);
} else if (button.dataset.action === "clear") {
state.current = "0";
state.previous = null;
state.operator = null;
} else if (button.dataset.action === "delete") {
state.current = state.current.slice(0, -1) || "0";
} else if (button.dataset.action === "equals") {
equals();
}
render();
});
function equals() {
if (state.operator === null || state.previous === null) return;
const result = calculate(state.previous, state.current, state.operator);
if (result === null) { showError(); return; }
state.current = String(Number(result.toFixed(10))); // trim float noise
state.previous = null;
state.operator = null;
}
function showError() {
state.current = "Cannot divide by 0";
state.previous = null;
state.operator = null;
}
render();One listener on the container instead of nineteen on the buttons — see JavaScript events explained.
Step 5: keyboard support#
Ten lines that make it feel finished:
document.addEventListener("keydown", (event) => {
const key = event.key;
if (/^[0-9.]$/.test(key)) inputNumber(key);
else if (["+", "-", "*", "/"].includes(key)) chooseOperator(key);
else if (key === "Enter" || key === "=") equals();
else if (key === "Backspace") state.current = state.current.slice(0, -1) || "0";
else if (key === "Escape") { state.current = "0"; state.previous = null; state.operator = null; }
else return;
render();
});How the code works#
The whole app is one loop: a key is pressed → the state object changes → render draws the screen. Nothing ever reads the display to find out what the calculator is doing, which is what keeps it predictable. If you can hold that pattern in your head, most front-end frameworks will feel familiar when you meet them.
Questions people ask#
Why not use eval()?
eval("2+3") works and is a bad habit. It runs whatever text it is given, which is a serious problem the moment any of that text comes from somewhere you do not control. Writing the arithmetic yourself is also the part that teaches you something.
Why keep numbers as strings?
Because you are building up what the user typed, character by character, and "1" + "2" needs to be "12", not 3. Convert to a number only when you actually calculate.
How would I handle operator precedence?
This calculator works left to right, like a pocket calculator — 2 + 3 * 4 gives 20. Proper precedence means parsing the whole expression rather than evaluating pair by pair, which is a considerably bigger project.