LearnAI ToolsCareerPractice BuildsPlayContact
JavaScriptIntermediate~2 hours

Calculator App

Build a fully working calculator with keyboard support and operator chaining.

FunctionsEventsDOM Manipulation

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.

What You'll Build
  • 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 variables
const 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 other
function 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
}
How state Changes During "12 + 7 * 2"

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 mouse
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(); // 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 runs

Complete 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();
Live Preview

Sample Run

Sample Interaction

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.