LearnAI ToolsCareerPractice BuildsPlayDevelopmentContact
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 ids 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.