Skip to main content

Project 08 · Intermediate

Calculator

A calculator that works with clicks and with the keyboard. There's no shortcut: you'll keep track of the numbers yourself, do the maths with a switch, and handle the awkward cases like 0.1 + 0.2 and dividing by zero.

  • 3 HTML ideas
  • 2 CSS ideas
  • 7 JavaScript ideas

Why build it

Remembering values across many key presses is what you need for a tip calculator, a unit converter, a loan repayment calculator, or a PIN pad. Handling clicks and the keyboard through the same functions is also how you'd add keyboard shortcuts to any app, like pressing N for a new note.

Skills you'll earn

  • Remember values across many clicks, like a sum that's only half typed
  • Handle a whole group of buttons with one event listener
  • Pick between several cases cleanly with switch
  • Make every button work from the keyboard too, through the same functions
  • Fix the rounding errors JavaScript makes with decimals, like 0.1 + 0.2

01

Try it

Play with it first, then read the code that makes it work.

Result0px
New tab (opens in a new tab)
Download
<!doctype html>
<html lang="en">
	<head>
		<meta charset="UTF-8" />
		<meta name="viewport" content="width=device-width, initial-scale=1.0" />
		<title>Calculator</title>
		<link rel="stylesheet" href="style.css" />
	</head>
	<body>
		<main class="calculator">
			<h1 class="visually-hidden">Calculator</h1>

			<div class="screen">
				<p class="expression" id="expression"></p>
				<!-- <output> is made for results; aria-live reads each new value out. -->
				<output class="display" id="display" aria-live="polite">0</output>
			</div>

			<!-- data- attributes say what each key does, so one listener handles all of them. -->
			<div class="keys" id="keys">
				<button type="button" class="key key-wide key-light" data-action="clear">
					AC
				</button>
				<button
					type="button"
					class="key key-light"
					data-action="delete"
					aria-label="Delete"
				>
					⌫
				</button>
				<button
					type="button"
					class="key key-operator"
					data-operator="/"
					aria-label="Divide"
				>
					÷
				</button>

				<button type="button" class="key" data-digit="7">7</button>
				<button type="button" class="key" data-digit="8">8</button>
				<button type="button" class="key" data-digit="9">9</button>
				<button
					type="button"
					class="key key-operator"
					data-operator="*"
					aria-label="Multiply"
				>
					×
				</button>

				<button type="button" class="key" data-digit="4">4</button>
				<button type="button" class="key" data-digit="5">5</button>
				<button type="button" class="key" data-digit="6">6</button>
				<button
					type="button"
					class="key key-operator"
					data-operator="-"
					aria-label="Subtract"
				>
					−
				</button>

				<button type="button" class="key" data-digit="1">1</button>
				<button type="button" class="key" data-digit="2">2</button>
				<button type="button" class="key" data-digit="3">3</button>
				<button
					type="button"
					class="key key-operator"
					data-operator="+"
					aria-label="Add"
				>
					+
				</button>

				<button type="button" class="key key-wide" data-digit="0">0</button>
				<button
					type="button"
					class="key"
					data-action="decimal"
					aria-label="Decimal point"
				>
					.
				</button>
				<button
					type="button"
					class="key key-equals"
					data-action="equals"
					aria-label="Equals"
				>
					=
				</button>
			</div>
		</main>

		<script src="script.js"></script>
	</body>
</html>

02

How it works

The ideas behind it, one at a time, each next to the code that does it.

HTMLthe structure

  1. 01

    Show results in an <output>

    <output> is the HTML element meant for the result of a calculation, so it describes this job better than a plain <div>. Screen reader users can't see the number change as they press keys. aria-live="polite" makes it a live region, so each new value is read out, and people hear what they've typed and the answer.

    			<div class="screen">
    				<p class="expression" id="expression"></p>
    				<!-- <output> is made for results; aria-live reads each new value out. -->
    				<output class="display" id="display" aria-live="polite">0</output>
    			</div>
  2. 02

    Label every key with data- attributes

    Attributes that start with data- are your own labels: the browser ignores them, and JavaScript can read them. Each key says what it does with data-digit, data-operator, or data-action. That keeps the visible symbol separate from the one the code uses, so the key can show × while data-operator holds *, the sign JavaScript multiplies with.

    			<!-- data- attributes say what each key does, so one listener handles all of them. -->
    			<div class="keys" id="keys">
    				<button type="button" class="key key-wide key-light" data-action="clear">
    					AC
    				</button>
    				<button
    					type="button"
    					class="key key-light"
    					data-action="delete"
    					aria-label="Delete"
    				>
    					⌫
    				</button>
    				<button
    					type="button"
    					class="key key-operator"
    					data-operator="/"
    					aria-label="Divide"
    				>
    					÷
    				</button>
  3. 03

    Name symbol keys with aria-label

    A button's name usually comes from its text. For a symbol like ÷, some screen readers say nothing and others say something awkward like "division sign". aria-label replaces the name screen readers announce, so aria-label="Divide" makes the key say what it does. The ⌫, ×, −, + and = keys get the same treatment.

    				<button
    					type="button"
    					class="key key-operator"
    					data-operator="/"
    					aria-label="Divide"
    				>
    					÷
    				</button>

CSSthe look

  1. 04

    Hide a heading visually but not from screen readers

    Screen reader users often move around a page by its headings, so the page needs an <h1> even though sighted users can see it's a calculator. display: none would hide it from screen readers too. The .visually-hidden class instead shrinks the heading to a 1px box and clips away everything inside, so it's invisible on screen but still in the page to be read.

    /* Hidden on screen but still read by screen readers. */
    .visually-hidden {
    	position: absolute;
    	width: 1px;
    	height: 1px;
    	overflow: hidden;
    	clip-path: inset(50%);
    	white-space: nowrap;
    }
  2. 05

    Lay out the keys with a grid

    CSS grid lays items out in rows and columns. grid-template-columns: repeat(4, 1fr) makes four columns, and 1fr means each gets an equal share of the space. The keys fill the grid in order, row by row. .key-wide adds grid-column: span 2, so the AC and 0 keys each stretch across two columns.

    /* Four equal columns; wide keys span two of them. */
    .keys {
    	display: grid;
    	grid-template-columns: repeat(4, 1fr);
    	gap: 0.625rem;
    }
    
    .key {
    	aspect-ratio: 1;
    	border: 0;
    	border-radius: 999px;
    	background: #3a3a44;
    	font: inherit;
    	font-size: 1.375rem;
    	color: #ffffff;
    	cursor: pointer;
    }
    
    .key-wide {
    	grid-column: span 2;
    	aspect-ratio: auto;
    }

JavaScriptthe behaviour

  1. 06

    Remember the sum in variables

    A calculator has to remember a sum while you build it, so that lives in variables, not in the page. current is the number being typed, previous the one before the operator, operator the chosen sign, and isNewNumber whether the next digit starts a fresh number. updateDisplay() draws the screen from these variables; the code never reads the screen back.

    // What the calculator remembers: the number on screen (as text, so digits
    // can be added one at a time), the number before it, and the operator.
    let current = "0";
    let previous = null;
    let operator = null;
    // True right after an operator or =, so the next digit starts a new number.
    let isNewNumber = false;
    
    const SYMBOLS = { "+": "+", "-": "−", "*": "×", "/": "÷" };
    
    function updateDisplay() {
    	display.textContent = current;
    	expression.textContent = operator ? `${previous} ${SYMBOLS[operator]}` : "";
    }
  2. 07

    Keep the typed number as text

    current is kept as text (a string), not a number, because you build it one key at a time. As a number, 5. would lose its decimal point and 0.0, on the way to typing 0.05, would collapse to 0. As text, each digit is added to the end with +=. Number() turns the text into a real number only when it's time to calculate.

    function inputDigit(digit) {
    	if (isNewNumber || current === "0") {
    		current = digit;
    		isNewNumber = false;
    	} else {
    		current += digit;
    	}
    }
    
    function inputDecimal() {
    	if (isNewNumber) {
    		current = "0";
    		isNewNumber = false;
    	}
    	// A number can only have one decimal point.
    	if (!current.includes(".")) {
    		current += ".";
    	}
    }
  3. 08

    Do the maths with switch, not eval

    A switch compares one value against a list of cases and runs the code for the first match, which reads more clearly than a chain of ifs. Some calculators use eval, which runs any text as JavaScript; that's risky and hard to control. With switch, calculate() only knows four operations: add, subtract, multiply, and divide.

    // A switch picks one case to run; it's tidier than four if statements.
    function calculate(a, b, chosenOperator) {
    	switch (chosenOperator) {
    		case "+":
    			return a + b;
    		case "-":
    			return a - b;
    		case "*":
    			return a * b;
    		case "/":
    			return a / b;
    	}
    }
  4. 09

    Catch dividing by zero

    In JavaScript, dividing by zero doesn't cause an error: 5 / 0 gives Infinity, and 0 / 0 gives NaN ("not a number"). Neither is a useful answer. Number.isFinite() is true only for ordinary numbers, so it catches both. When it fails, the calculator resets, shows "Can't divide by 0", and treats the next digit as a new number.

    	// Dividing by zero gives Infinity, which isn't a useful answer.
    	if (!Number.isFinite(result)) {
    		clearAll();
    		current = "Can't divide by 0";
    		isNewNumber = true;
    		return;
    	}
  5. 10

    Round away floating-point errors

    Computers store most decimals as very close approximations, so 0.1 + 0.2 gives 0.30000000000000004. toFixed(10) rounds the result to 10 decimal places, which hides the error but returns text with trailing zeros, like "0.3000000000". parseFloat turns that back into a number and drops the zeros, and String() makes it text again for current.

    	// 0.1 + 0.2 is 0.30000000000000004 in JavaScript; rounding hides that.
    	current = String(parseFloat(result.toFixed(10)));
  6. 11

    Use one listener for every key

    Clicks bubble: a click on a button also counts as a click on everything that contains it. So instead of 18 listeners, one on the keys container hears them all. event.target is what was actually clicked, and closest("button") finds the key it belongs to. The key's dataset holds its data- attributes, which decide which function to call.

    // One listener for every key: closest() finds the button that was clicked.
    keys.addEventListener("click", (event) => {
    	const key = event.target.closest("button");
    	if (!key) return;
    
    	const { digit, operator: keyOperator, action } = key.dataset;
    	if (digit) inputDigit(digit);
    	else if (keyOperator) chooseOperator(keyOperator);
    	else if (action === "decimal") inputDecimal();
    	else if (action === "equals") equals();
    	else if (action === "delete") deleteLast();
    	else if (action === "clear") clearAll();
    
    	updateDisplay();
    });
  7. 12

    Let the keyboard call the same functions

    A keydown listener on the whole document hears every key press, and event.key says which key it was. Each key calls the same function the matching button uses, so there's only one version of each action. If someone clicks a key and then presses Enter, the browser would also press that focused button, so preventDefault() stops the browser's own response.

    // The keyboard calls the same functions as the buttons.
    document.addEventListener("keydown", (event) => {
    	const { key } = event;
    	if ("0123456789".includes(key)) inputDigit(key);
    	else if (key in SYMBOLS) chooseOperator(key);
    	else if (key === ".") inputDecimal();
    	else if (key === "Enter" || key === "=") equals();
    	else if (key === "Backspace") deleteLast();
    	else if (key === "Escape") clearAll();
    	else return;
    
    	// Stop Enter from also pressing whichever button has focus.
    	event.preventDefault();
    	updateDisplay();
    });

03

Wrap up

Track your progress, then go deeper or keep going.

Take it further

Once it works, try these on your own. Each one uses what you've just learned, plus one new idea.

  1. Challenge 01

    Add a percentage key and a key that switches between positive and negative.

  2. Challenge 02

    Show a history of past sums that you can click to reuse.

  3. Challenge 03

    Format long numbers with thousands separators using Intl.NumberFormat.

Articles that go further into ideas from this project.

Finished Calculator?

Mark it done to track your progress.

You finished Calculator!

0 of 15 projects done.

Go deeper

The full article explains each idea in more depth, why the code is written this way, and more than fits on this page.

Article coming soon. See the series so far (opens in a new tab)