Overview
A calculator looks simple but is a genuinely good project for practicing functions and state management, because every button press has to update a small amount of shared state correctly, and getting operator chaining right (typing `12 + 7 * 2` and having it behave the way a real calculator does) forces you to think carefully about exactly when a pending calculation should resolve.
This tutorial builds the calculator around one plain state object that every function reads from and writes to, a single display-updating function, and small, single-purpose functions for each operation. The same logic is triggered two ways — clicking the on-screen buttons, and pressing the matching keys on a real keyboard — which is what makes the calculator "fully working" rather than a demo you can only use with a mouse.
- A calculator display driven entirely by a JavaScript `state` object.
- Number and decimal-point input with guards against invalid values like "1.2.3".
- Operator chaining, so pressing `+` then `*` correctly evaluates the pending operation first.
- An equals button that computes the final result, and clear/delete buttons for correcting mistakes.
- Full keyboard support, so every on-screen button also has a matching key.
Prerequisites
- Functions — declaring named functions and calling them from event handlers.
- Events — `addEventListener` for both `click` and `keydown`, and reading `event.key`.
- DOM basics — `textContent`, `dataset`, and `event.target.closest()`.
- Basic arithmetic in JavaScript, including `parseFloat` and the `switch` statement.
- Objects, for grouping the calculator's current value, pending operator, and previous value into one `state` object.
Project Structure
The calculator is a display `<div>` above a grid of buttons. Every number button carries a `data-number` attribute, every operator button a `data-operator` attribute, and the remaining buttons a `data-action` attribute (`clear`, `delete`, or `equals`) — the click handler reads whichever attribute is present to decide what to do, instead of needing a separate listener per button.
All of the calculator's memory lives in one `state` object: the value currently on the display, the operator waiting to be applied, the previous value it will be applied to, and a flag that tracks whether the next digit typed should start a new number or continue the current one. Every function below either reads this object to decide what to do, or updates it and then calls `updateDisplay()`.
<div class="calculator"> <!-- Always reflects state.currentValue; JS writes to this, nothing else does --> <div class="display" id="display">0</div>
<div class="buttons"> <button data-action="clear" class="span-2">C</button> <button data-action="delete">DEL</button> <button data-operator="/">÷</button>
<button data-number="7">7</button> <button data-number="8">8</button> <button data-number="9">9</button> <button data-operator="*">×</button>
<button data-number="4">4</button> <button data-number="5">5</button> <button data-number="6">6</button> <button data-operator="-">−</button>
<button data-number="1">1</button> <button data-number="2">2</button> <button data-number="3">3</button> <button data-operator="+">+</button>
<button data-number="0" class="span-2">0</button> <button data-number=".">.</button> <button data-action="equals">=</button> </div></div>Using `data-*` attributes instead of separate `id`s on every button is what lets a single click listener on `.buttons` handle all seventeen buttons — the handler checks `dataset.number`, `dataset.operator`, and `dataset.action` in turn to figure out what was pressed.
* { box-sizing: border-box; margin: 0; padding: 0;}
body { font-family: Arial, Helvetica, sans-serif; background: #1a1d23; color: #e8e8e8; display: flex; justify-content: center; padding: 40px 16px;}
.calculator { width: 100%; max-width: 320px; background: #23262e; border-radius: 10px; padding: 16px;}
.display { background: #1a1d23; border-radius: 8px; padding: 20px 16px; text-align: right; font-size: 2rem; margin-bottom: 12px; overflow-x: auto; /* Long results scroll instead of overflowing the card */ white-space: nowrap;}
.buttons { display: grid; grid-template-columns: repeat(4, 1fr); /* Four equal-width columns */ gap: 8px;}
.buttons button { padding: 16px 0; font-size: 1.1rem; border: none; border-radius: 8px; background: #3a3f4b; color: #e8e8e8; cursor: pointer;}
.buttons button:active { background: #4f8cff; /* Immediate visual feedback on press, mouse or touch */}
.buttons button[data-operator],.buttons button[data-action="equals"] { background: #4f8cff; color: white; font-weight: bold;}
.buttons button[data-action="clear"],.buttons button[data-action="delete"] { background: #5a3a3a;}
.span-2 { grid-column: span 2; /* "C" and "0" are wider, spanning two grid columns */}Step 1: Select Elements and Define State
The `state` object is declared once and never replaced — every function below mutates its properties in place. `overwrite` is the trickiest field to understand: it is `true` any time the next digit typed should start a brand-new number instead of appending to whatever is already showing.
const displayEl = document.getElementById('display');const buttonsEl = document.querySelector('.buttons');
// One object holds everything the calculator needs to remember between// button presses, instead of scattering separate global variablesconst state = { currentValue: '0', // What's currently shown on the display, kept as a string so "1." can exist mid-typing previousValue: null, // The operand entered before the operator, held until "=" or the next operator resolves it operator: null, // '+', '-', '*', or '/' — null means no operation is pending overwrite: true, // True right after an operator or equals, so the next digit starts a fresh number};Step 2: Update the Display
Every other function in this project ends by calling `updateDisplay()` rather than touching `displayEl` directly — that keeps "what the state says" and "what the screen shows" from ever drifting apart.
// The only function allowed to write to the display, so state and the// screen can never disagree with each otherfunction updateDisplay() { displayEl.textContent = state.currentValue;}Step 3: Handle Number and Decimal Input
`inputNumber` handles three different situations: starting a fresh number after an operator, typing a second decimal point (which should be ignored), and the ordinary case of just appending a digit to what is already on screen.
function inputNumber(digit) { if (state.overwrite) { state.currentValue = digit === '.' ? '0.' : digit; // Typing "." first shows "0." instead of just "." state.overwrite = false; // Further digits should now append, not overwrite } else if (digit === '.' && state.currentValue.includes('.')) { return; // Already has a decimal point — ignore a second one } else if (state.currentValue === '0' && digit !== '.') { state.currentValue = digit; // Replace a leading solitary "0" instead of showing "05" } else { state.currentValue += digit; // Normal case: append the typed digit to what's already there } updateDisplay();}Step 4: Compute Results and Chain Operators
`compute()` turns the current pending operation into a number using `parseFloat` and a `switch` on the operator. `chooseOperator()` is where chaining actually happens: if an operator is already pending and the user has typed a new number since, it resolves that pending operation first before recording the newly pressed operator.
function compute() { const prev = parseFloat(state.previousValue); const curr = parseFloat(state.currentValue); if (isNaN(prev) || isNaN(curr)) return curr; // Nothing to combine yet, just pass the current value through
switch (state.operator) { case '+': return prev + curr; case '-': return prev - curr; case '*': return prev * curr; case '/': return curr === 0 ? NaN : prev / curr; // Guard divide-by-zero instead of showing Infinity default: return curr; }}
function chooseOperator(operator) { if (state.operator && !state.overwrite) { // An operator is already pending AND a new number has been typed since — // that's operator chaining (e.g. "12 + 7 *"): resolve the pending // operation first so the running total becomes the new left-hand operand const result = compute(); state.currentValue = String(result); updateDisplay(); }
state.previousValue = state.currentValue; // This becomes the left-hand operand for the next computation state.operator = operator; state.overwrite = true; // The next digit typed should start a fresh number}Click Run to see what this code prints.
Step 5: Handle Equals, Clear, and Delete
`equals()` resolves whatever operation is currently pending and resets `operator`/`previousValue` so the next digit typed starts a brand-new calculation from scratch, rather than continuing to chain onto the result.
function equals() { if (state.operator === null) return; // Nothing pending to compute
const result = compute(); state.currentValue = String(result); state.previousValue = null; state.operator = null; state.overwrite = true; // Next digit typed starts a brand-new calculation updateDisplay();}
function clearAll() { state.currentValue = '0'; state.previousValue = null; state.operator = null; state.overwrite = true; updateDisplay();}
function deleteLast() { if (state.overwrite) return; // Nothing typed yet this turn — nothing to delete state.currentValue = state.currentValue.length > 1 ? state.currentValue.slice(0, -1) : '0'; updateDisplay();}Step 6: Wire Up Clicks and Keyboard Support
The click handler on `.buttons` uses event delegation and `dataset` to figure out which of the five functions above to call. The `keydown` listener on `document` calls exactly the same functions, mapping each relevant key to its on-screen equivalent — the calculator has no idea (and does not need to know) whether a given press came from a mouse or a keyboard.
buttonsEl.addEventListener('click', function (event) { const btn = event.target.closest('button'); if (!btn) return;
if (btn.dataset.number !== undefined) { inputNumber(btn.dataset.number); } else if (btn.dataset.operator) { chooseOperator(btn.dataset.operator); } else if (btn.dataset.action === 'equals') { equals(); } else if (btn.dataset.action === 'clear') { clearAll(); } else if (btn.dataset.action === 'delete') { deleteLast(); }});
// Mirrors every click behavior for the keyboard, so the calculator is fully usable without a mousedocument.addEventListener('keydown', function (event) { if (event.key >= '0' && event.key <= '9') { inputNumber(event.key); } else if (event.key === '.') { inputNumber('.'); } else if (['+', '-', '*', '/'].includes(event.key)) { chooseOperator(event.key); } else if (event.key === 'Enter' || event.key === '=') { event.preventDefault(); // Enter can submit a nearby form in a real page; block that default here equals(); } else if (event.key === 'Backspace') { deleteLast(); } else if (event.key === 'Escape') { clearAll(); }});
updateDisplay(); // Draw the initial "0" as soon as the script runsComplete Code
Here is the complete project — the HTML shell, the CSS, and the fully assembled JavaScript in the order you would put them in real files (`index.html`, `styles.css`, `script.js`).
<div class="calculator"> <div class="display" id="display">0</div>
<div class="buttons"> <button data-action="clear" class="span-2">C</button> <button data-action="delete">DEL</button> <button data-operator="/">÷</button>
<button data-number="7">7</button> <button data-number="8">8</button> <button data-number="9">9</button> <button data-operator="*">×</button>
<button data-number="4">4</button> <button data-number="5">5</button> <button data-number="6">6</button> <button data-operator="-">−</button>
<button data-number="1">1</button> <button data-number="2">2</button> <button data-number="3">3</button> <button data-operator="+">+</button>
<button data-number="0" class="span-2">0</button> <button data-number=".">.</button> <button data-action="equals">=</button> </div></div>* { box-sizing: border-box; margin: 0; padding: 0;}
body { font-family: Arial, Helvetica, sans-serif; background: #1a1d23; color: #e8e8e8; display: flex; justify-content: center; padding: 40px 16px;}
.calculator { width: 100%; max-width: 320px; background: #23262e; border-radius: 10px; padding: 16px;}
.display { background: #1a1d23; border-radius: 8px; padding: 20px 16px; text-align: right; font-size: 2rem; margin-bottom: 12px; overflow-x: auto; white-space: nowrap;}
.buttons { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px;}
.buttons button { padding: 16px 0; font-size: 1.1rem; border: none; border-radius: 8px; background: #3a3f4b; color: #e8e8e8; cursor: pointer;}
.buttons button:active { background: #4f8cff;}
.buttons button[data-operator],.buttons button[data-action="equals"] { background: #4f8cff; color: white; font-weight: bold;}
.buttons button[data-action="clear"],.buttons button[data-action="delete"] { background: #5a3a3a;}
.span-2 { grid-column: span 2;}const displayEl = document.getElementById('display');const buttonsEl = document.querySelector('.buttons');
const state = { currentValue: '0', previousValue: null, operator: null, overwrite: true,};
function updateDisplay() { displayEl.textContent = state.currentValue;}
function inputNumber(digit) { if (state.overwrite) { state.currentValue = digit === '.' ? '0.' : digit; state.overwrite = false; } else if (digit === '.' && state.currentValue.includes('.')) { return; } else if (state.currentValue === '0' && digit !== '.') { state.currentValue = digit; } else { state.currentValue += digit; } updateDisplay();}
function compute() { const prev = parseFloat(state.previousValue); const curr = parseFloat(state.currentValue); if (isNaN(prev) || isNaN(curr)) return curr;
switch (state.operator) { case '+': return prev + curr; case '-': return prev - curr; case '*': return prev * curr; case '/': return curr === 0 ? NaN : prev / curr; default: return curr; }}
function chooseOperator(operator) { if (state.operator && !state.overwrite) { const result = compute(); state.currentValue = String(result); updateDisplay(); }
state.previousValue = state.currentValue; state.operator = operator; state.overwrite = true;}
function equals() { if (state.operator === null) return;
const result = compute(); state.currentValue = String(result); state.previousValue = null; state.operator = null; state.overwrite = true; updateDisplay();}
function clearAll() { state.currentValue = '0'; state.previousValue = null; state.operator = null; state.overwrite = true; updateDisplay();}
function deleteLast() { if (state.overwrite) return; state.currentValue = state.currentValue.length > 1 ? state.currentValue.slice(0, -1) : '0'; updateDisplay();}
buttonsEl.addEventListener('click', function (event) { const btn = event.target.closest('button'); if (!btn) return;
if (btn.dataset.number !== undefined) { inputNumber(btn.dataset.number); } else if (btn.dataset.operator) { chooseOperator(btn.dataset.operator); } else if (btn.dataset.action === 'equals') { equals(); } else if (btn.dataset.action === 'clear') { clearAll(); } else if (btn.dataset.action === 'delete') { deleteLast(); }});
document.addEventListener('keydown', function (event) { if (event.key >= '0' && event.key <= '9') { inputNumber(event.key); } else if (event.key === '.') { inputNumber('.'); } else if (['+', '-', '*', '/'].includes(event.key)) { chooseOperator(event.key); } else if (event.key === 'Enter' || event.key === '=') { event.preventDefault(); equals(); } else if (event.key === 'Backspace') { deleteLast(); } else if (event.key === 'Escape') { clearAll(); }});
updateDisplay();Sample Run
Click Run to see what this code prints.
Extend This Project
- Add a percentage button (`%`) that divides the current value by 100 in place.
- Add simple memory buttons (`M+`, `M-`, `MR`, `MC`) backed by one more field on the `state` object.
- Keep a running history of past calculations in an array, and render it as a scrollable log above the display.
- Show the pending operation (e.g. "12 +") in a small secondary line above the main display.
- Add a square-root or exponent button, extending `compute()` with unary operations that only need `currentValue`.
Summary
You built a fully working calculator, complete with keyboard support and correct operator chaining, using nothing more than a plain state object, a handful of small functions, and two event listeners. The key idea — one function per responsibility, all reading and writing the same central state — scales far beyond a calculator and is exactly how larger interactive UIs are organized before you ever reach for a framework.