Build a JS Calculator: Beginner DOM Mini Project
Build a JavaScript calculator app from scratch. Learn DOM manipulation, event delegation, keyboard input, operator logic, and display formatting in this beginner project.
Building a calculator is a classic JavaScript project that teaches state management, operator logic, event delegation, and display formatting. Unlike a simple counter, a calculator must track two operands, an operator, and handle chained operations. This tutorial builds the app step by step from basic addition to a fully featured calculator with keyboard support, decimal handling, and error states.
What You Will Build
| Feature | Description |
|---|---|
| Basic operations | Addition, subtraction, multiplication, division |
| Chained operations | 5 + 3 * 2 evaluates left to right |
| Decimal support | Numbers with decimal points |
| Percentage | Quick percentage calculation |
| Keyboard input | Type numbers and operators from the keyboard |
| Clear / Delete | AC to clear all, DEL to delete last digit |
| Error handling | Division by zero, overflow protection |
| Display formatting | Commas for thousands, max digits |
Step 1: HTML and CSS
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Calculator</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; background: #1a1a2e; display: flex; justify-content: center; align-items: center; min-height: 100vh; }
.calculator { background: #16213e; border-radius: 16px; box-shadow: 0 8px 32px rgba(0,0,0,0.4); overflow: hidden; width: 320px; }
.display { background: #0a192f; padding: 24px 20px 16px; text-align: right; min-height: 100px; display: flex; flex-direction: column; justify-content: flex-end; }
.display .previous { font-size: 14px; color: #8892b0; min-height: 20px; word-break: break-all; }
.display .current { font-size: 40px; color: #ccd6f6; font-weight: 600; word-break: break-all; margin-top: 4px; }
.buttons { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1px; background: #1a1a2e; }
.btn { padding: 20px; font-size: 20px; border: none; cursor: pointer; transition: background 0.15s; background: #16213e; color: #ccd6f6; }
.btn:hover { background: #1a2744; }
.btn:active { background: #0a192f; }
.btn-operator { background: #1e3a5f; color: #64ffda; }
.btn-operator:hover { background: #254a73; }
.btn-equals { background: #64ffda; color: #0a192f; font-weight: 700; }
.btn-equals:hover { background: #52e0c4; }
.btn-clear { background: #e74c3c; color: white; }
.btn-clear:hover { background: #c0392b; }
.btn-delete { background: #e67e22; color: white; }
.btn-delete:hover { background: #d35400; }
.btn-wide { grid-column: span 2; }
.error .current { color: #ff6b6b; }
</style>
</head>
<body>
<div class="calculator" id="calculator">
<div class="display" id="display">
<div class="previous" id="previous-display"></div>
<div class="current" id="current-display">0</div>
</div>
<div class="buttons" id="buttons">
<button class="btn btn-clear" data-action="clear">AC</button>
<button class="btn btn-delete" data-action="delete">DEL</button>
<button class="btn btn-operator" data-action="percent">%</button>
<button class="btn btn-operator" data-action="operator" data-value="/">÷</button>
<button class="btn" data-action="number" data-value="7">7</button>
<button class="btn" data-action="number" data-value="8">8</button>
<button class="btn" data-action="number" data-value="9">9</button>
<button class="btn btn-operator" data-action="operator" data-value="*">×</button>
<button class="btn" data-action="number" data-value="4">4</button>
<button class="btn" data-action="number" data-value="5">5</button>
<button class="btn" data-action="number" data-value="6">6</button>
<button class="btn btn-operator" data-action="operator" data-value="-">−</button>
<button class="btn" data-action="number" data-value="1">1</button>
<button class="btn" data-action="number" data-value="2">2</button>
<button class="btn" data-action="number" data-value="3">3</button>
<button class="btn btn-operator" data-action="operator" data-value="+">+</button>
<button class="btn btn-wide" data-action="number" data-value="0">0</button>
<button class="btn" data-action="decimal">.</button>
<button class="btn btn-equals" data-action="equals">=</button>
</div>
</div>
<script src="calculator.js"></script>
</body>
</html>Step 2: Calculator State
The calculator tracks the current number being typed, the previous operand, the operator, and whether the display should reset on the next keystroke:
// calculator.js
const state = {
currentOperand: "0",
previousOperand: "",
operator: null,
shouldResetDisplay: false,
hasError: false
};
const MAX_DIGITS = 12;Step 3: DOM References and Display
const currentDisplay = document.getElementById("current-display");
const previousDisplay = document.getElementById("previous-display");
const displayEl = document.getElementById("display");
const buttonsContainer = document.getElementById("buttons");
function formatNumber(numStr) {
if (numStr === "" || numStr === "Error") return numStr;
const parts = numStr.split(".");
const intPart = parts[0];
const decPart = parts[1];
// Add commas to integer part
const formatted = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
return decPart !== undefined ? `${formatted}.${decPart}` : formatted;
}
function updateDisplay() {
displayEl.classList.toggle("error", state.hasError);
currentDisplay.textContent = state.hasError
? state.currentOperand
: formatNumber(state.currentOperand);
if (state.operator && state.previousOperand) {
const opSymbol = { "+": "+", "-": "−", "*": "×", "/": "÷" }[state.operator];
previousDisplay.textContent = `${formatNumber(state.previousOperand)} ${opSymbol}`;
} else {
previousDisplay.textContent = "";
}
}Step 4: Number Input Logic
function inputNumber(digit) {
if (state.hasError) clearAll();
if (state.shouldResetDisplay) {
state.currentOperand = digit;
state.shouldResetDisplay = false;
} else if (state.currentOperand === "0") {
state.currentOperand = digit;
} else if (state.currentOperand.replace(".", "").replace("-", "").length >= MAX_DIGITS) {
return; // Max digits reached
} else {
state.currentOperand += digit;
}
updateDisplay();
}
function inputDecimal() {
if (state.hasError) clearAll();
if (state.shouldResetDisplay) {
state.currentOperand = "0.";
state.shouldResetDisplay = false;
updateDisplay();
return;
}
// Only one decimal point allowed
if (state.currentOperand.includes(".")) return;
state.currentOperand += ".";
updateDisplay();
}Step 5: Operator and Calculation Logic
function calculate() {
const prev = parseFloat(state.previousOperand);
const current = parseFloat(state.currentOperand);
if (isNaN(prev) || isNaN(current)) return null;
let result;
switch (state.operator) {
case "+": result = prev + current; break;
case "-": result = prev - current; break;
case "*": result = prev * current; break;
case "/":
if (current === 0) return "Error";
result = prev / current;
break;
default: return null;
}
// Handle floating point precision
return parseFloat(result.toPrecision(MAX_DIGITS)).toString();
}
function inputOperator(op) {
if (state.hasError) clearAll();
// If there is a pending operation, calculate it first (chaining)
if (state.operator && !state.shouldResetDisplay) {
const result = calculate();
if (result === "Error") {
showError();
return;
}
if (result !== null) {
state.currentOperand = result;
}
}
state.previousOperand = state.currentOperand;
state.operator = op;
state.shouldResetDisplay = true;
updateDisplay();
}
function inputEquals() {
if (!state.operator || state.hasError) return;
const result = calculate();
if (result === "Error") {
showError();
return;
}
if (result === null) return;
state.currentOperand = result;
state.previousOperand = "";
state.operator = null;
state.shouldResetDisplay = true;
updateDisplay();
}
function inputPercent() {
if (state.hasError) return;
const current = parseFloat(state.currentOperand);
if (isNaN(current)) return;
if (state.operator && state.previousOperand) {
// 100 + 10% = 100 + (100 * 0.1) = 110
const prev = parseFloat(state.previousOperand);
state.currentOperand = (prev * (current / 100)).toString();
} else {
state.currentOperand = (current / 100).toString();
}
updateDisplay();
}Step 6: Clear, Delete, and Error Handling
function clearAll() {
state.currentOperand = "0";
state.previousOperand = "";
state.operator = null;
state.shouldResetDisplay = false;
state.hasError = false;
updateDisplay();
}
function deleteLast() {
if (state.hasError || state.shouldResetDisplay) {
clearAll();
return;
}
if (state.currentOperand.length === 1 || state.currentOperand === "0") {
state.currentOperand = "0";
} else {
state.currentOperand = state.currentOperand.slice(0, -1);
}
updateDisplay();
}
function showError() {
state.currentOperand = "Error";
state.previousOperand = "";
state.operator = null;
state.hasError = true;
state.shouldResetDisplay = true;
updateDisplay();
}Step 7: Event Handling with Delegation
Instead of attaching listeners to each button, use event delegation on the buttons container:
buttonsContainer.addEventListener("click", (e) => {
const button = e.target.closest(".btn");
if (!button) return;
const action = button.dataset.action;
const value = button.dataset.value;
switch (action) {
case "number": inputNumber(value); break;
case "decimal": inputDecimal(); break;
case "operator": inputOperator(value); break;
case "equals": inputEquals(); break;
case "clear": clearAll(); break;
case "delete": deleteLast(); break;
case "percent": inputPercent(); break;
}
});Step 8: Keyboard Support
Map keyboard events to calculator actions:
const keyMap = {
"0": () => inputNumber("0"),
"1": () => inputNumber("1"),
"2": () => inputNumber("2"),
"3": () => inputNumber("3"),
"4": () => inputNumber("4"),
"5": () => inputNumber("5"),
"6": () => inputNumber("6"),
"7": () => inputNumber("7"),
"8": () => inputNumber("8"),
"9": () => inputNumber("9"),
".": () => inputDecimal(),
"+": () => inputOperator("+"),
"-": () => inputOperator("-"),
"*": () => inputOperator("*"),
"/": () => inputOperator("/"),
"%": () => inputPercent(),
"Enter": () => inputEquals(),
"=": () => inputEquals(),
"Backspace": () => deleteLast(),
"Delete": () => clearAll(),
"Escape": () => clearAll()
};
document.addEventListener("keydown", (e) => {
const handler = keyMap[e.key];
if (handler) {
e.preventDefault();
handler();
}
});The Complete calculator.js File
// State
const state = {
currentOperand: "0",
previousOperand: "",
operator: null,
shouldResetDisplay: false,
hasError: false
};
const MAX_DIGITS = 12;
// DOM
const currentDisplay = document.getElementById("current-display");
const previousDisplay = document.getElementById("previous-display");
const displayEl = document.getElementById("display");
const buttonsContainer = document.getElementById("buttons");
// Display
function formatNumber(numStr) {
if (numStr === "" || numStr === "Error") return numStr;
const parts = numStr.split(".");
const formatted = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ",");
return parts[1] !== undefined ? `${formatted}.${parts[1]}` : formatted;
}
function updateDisplay() {
displayEl.classList.toggle("error", state.hasError);
currentDisplay.textContent = state.hasError ? state.currentOperand : formatNumber(state.currentOperand);
if (state.operator && state.previousOperand) {
const sym = { "+": "+", "-": "\u2212", "*": "\u00D7", "/": "\u00F7" }[state.operator];
previousDisplay.textContent = `${formatNumber(state.previousOperand)} ${sym}`;
} else {
previousDisplay.textContent = "";
}
}
// Input
function inputNumber(digit) {
if (state.hasError) clearAll();
if (state.shouldResetDisplay) { state.currentOperand = digit; state.shouldResetDisplay = false; }
else if (state.currentOperand === "0") { state.currentOperand = digit; }
else if (state.currentOperand.replace(/[.\-]/g, "").length >= MAX_DIGITS) { return; }
else { state.currentOperand += digit; }
updateDisplay();
}
function inputDecimal() {
if (state.hasError) clearAll();
if (state.shouldResetDisplay) { state.currentOperand = "0."; state.shouldResetDisplay = false; updateDisplay(); return; }
if (state.currentOperand.includes(".")) return;
state.currentOperand += ".";
updateDisplay();
}
// Calculation
function calculate() {
const prev = parseFloat(state.previousOperand);
const curr = parseFloat(state.currentOperand);
if (isNaN(prev) || isNaN(curr)) return null;
let result;
switch (state.operator) {
case "+": result = prev + curr; break;
case "-": result = prev - curr; break;
case "*": result = prev * curr; break;
case "/": if (curr === 0) return "Error"; result = prev / curr; break;
default: return null;
}
return parseFloat(result.toPrecision(MAX_DIGITS)).toString();
}
function inputOperator(op) {
if (state.hasError) clearAll();
if (state.operator && !state.shouldResetDisplay) {
const result = calculate();
if (result === "Error") { showError(); return; }
if (result !== null) state.currentOperand = result;
}
state.previousOperand = state.currentOperand;
state.operator = op;
state.shouldResetDisplay = true;
updateDisplay();
}
function inputEquals() {
if (!state.operator || state.hasError) return;
const result = calculate();
if (result === "Error") { showError(); return; }
if (result === null) return;
state.currentOperand = result;
state.previousOperand = "";
state.operator = null;
state.shouldResetDisplay = true;
updateDisplay();
}
function inputPercent() {
if (state.hasError) return;
const curr = parseFloat(state.currentOperand);
if (isNaN(curr)) return;
if (state.operator && state.previousOperand) {
state.currentOperand = (parseFloat(state.previousOperand) * (curr / 100)).toString();
} else {
state.currentOperand = (curr / 100).toString();
}
updateDisplay();
}
// Clear / Delete / Error
function clearAll() {
Object.assign(state, { currentOperand: "0", previousOperand: "", operator: null, shouldResetDisplay: false, hasError: false });
updateDisplay();
}
function deleteLast() {
if (state.hasError || state.shouldResetDisplay) { clearAll(); return; }
state.currentOperand = state.currentOperand.length <= 1 ? "0" : state.currentOperand.slice(0, -1);
updateDisplay();
}
function showError() {
Object.assign(state, { currentOperand: "Error", previousOperand: "", operator: null, hasError: true, shouldResetDisplay: true });
updateDisplay();
}
// Event delegation
buttonsContainer.addEventListener("click", (e) => {
const btn = e.target.closest(".btn");
if (!btn) return;
const { action, value } = btn.dataset;
if (action === "number") inputNumber(value);
else if (action === "decimal") inputDecimal();
else if (action === "operator") inputOperator(value);
else if (action === "equals") inputEquals();
else if (action === "clear") clearAll();
else if (action === "delete") deleteLast();
else if (action === "percent") inputPercent();
});
// Keyboard
const keyMap = { "0":()=>inputNumber("0"),"1":()=>inputNumber("1"),"2":()=>inputNumber("2"),"3":()=>inputNumber("3"),"4":()=>inputNumber("4"),"5":()=>inputNumber("5"),"6":()=>inputNumber("6"),"7":()=>inputNumber("7"),"8":()=>inputNumber("8"),"9":()=>inputNumber("9"),".":()=>inputDecimal(),"+":()=>inputOperator("+"),"-":()=>inputOperator("-"),"*":()=>inputOperator("*"),"/":()=>inputOperator("/"),"%":()=>inputPercent(),"Enter":()=>inputEquals(),"=":()=>inputEquals(),"Backspace":()=>deleteLast(),"Delete":()=>clearAll(),"Escape":()=>clearAll() };
document.addEventListener("keydown", (e) => {
const handler = keyMap[e.key];
if (handler) { e.preventDefault(); handler(); }
});
// Initialize
updateDisplay();Common Mistakes to Avoid
Mistake 1: Floating Point Precision
// The problem
console.log(0.1 + 0.2); // 0.30000000000000004
// The fix: use toPrecision then parseFloat
const result = 0.1 + 0.2;
console.log(parseFloat(result.toPrecision(12))); // 0.3Mistake 2: Not Handling Division by Zero
// BAD: No check
function divide(a, b) {
return a / b; // Returns Infinity when b is 0
}
// GOOD: Explicit error
function divide(a, b) {
if (b === 0) return "Error";
return a / b;
}Mistake 3: Allowing Multiple Decimal Points
// BAD: No guard
state.currentOperand += ".";
// GOOD: Check first
if (!state.currentOperand.includes(".")) {
state.currentOperand += ".";
}Concepts Practiced
| Concept | Where Used |
|---|---|
| Event delegation | Single listener on buttons container |
| Data attributes | data-action and data-value on buttons |
| Keyboard events | Full keyboard input mapping |
| Template literals | Display formatting strings |
| Switch statements | Operator routing and key mapping |
| Object state | Calculator state as a single object |
| String methods | Digit counting, slice, includes, replace |
| preventDefault | Blocking default keyboard actions |
Rune AI
Key Insights
- State object is the single source of truth: All calculator data lives in one object, every action updates it, every update re-renders the display
- Event delegation simplifies button handling: One listener on the container with data attributes routes to the correct function
- Keyboard mapping mirrors button actions: Map each key to the same functions buttons call for consistent behavior
- Handle edge cases early: Division by zero, multiple decimals, max digits, and floating point precision all need explicit guards
- Chained operations evaluate left to right: Calculate the pending operation before setting the new operator to support sequences like 5 + 3 * 2
Frequently Asked Questions
Why use a state object instead of separate variables?
How does operation chaining work (5 + 3 * 2)?
Why does the calculator use event delegation instead of individual button listeners?
How does the percentage button work contextually?
How can I extend this calculator with more features?
Conclusion
This calculator project combines event delegation for button clicks, keyboard event mapping for full keyboard support, state management with a single object, and display formatting with string manipulation. The key insight is that the state object is the single source of truth: every user action modifies state through a function, then calls updateDisplay() to sync the DOM. This same architecture scales to any interactive JavaScript application.
More in this topic
OffscreenCanvas API in JS for UI Performance
Master the OffscreenCanvas API to offload rendering from the main thread. Covers worker-based 2D and WebGL rendering, animation loops inside workers, bitmap transfer, double buffering, chart rendering pipelines, image processing, and performance measurement strategies.
Advanced Web Workers for High Performance JS
Master Web Workers for truly parallel JavaScript execution. Covers dedicated and shared workers, structured cloning, transferable objects, SharedArrayBuffer with Atomics, worker pools, task scheduling, Comlink RPC patterns, module workers, and performance profiling strategies.
JavaScript Macros and Abstract Code Generation
Master JavaScript code generation techniques for compile-time and runtime metaprogramming. Covers AST manipulation, Babel plugin authorship, tagged template literals as macros, code generation pipelines, source-to-source transformation, compile-time evaluation, and safe eval alternatives.