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.
<!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
- 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> - 02
Label every key with
data-attributesAttributes that start with
data-are your own labels: the browser ignores them, and JavaScript can read them. Each key says what it does withdata-digit,data-operator, ordata-action. That keeps the visible symbol separate from the one the code uses, so the key can show × whiledata-operatorholds*, 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> - 03
Name symbol keys with
aria-labelA 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-labelreplaces the name screen readers announce, soaria-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
- 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: nonewould hide it from screen readers too. The.visually-hiddenclass 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; } - 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, and1frmeans each gets an equal share of the space. The keys fill the grid in order, row by row..key-wideaddsgrid-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
- 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.
currentis the number being typed,previousthe one before the operator,operatorthe chosen sign, andisNewNumberwhether 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]}` : ""; } - 07
Keep the typed number as text
currentis 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 and0.0, on the way to typing0.05, would collapse to0. 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 += "."; } } - 08
Do the maths with
switch, notevalA
switchcompares one value against a list ofcases and runs the code for the first match, which reads more clearly than a chain ofifs. Some calculators useeval, which runs any text as JavaScript; that's risky and hard to control. Withswitch,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; } } - 09
Catch dividing by zero
In JavaScript, dividing by zero doesn't cause an error:
5 / 0givesInfinity, and0 / 0givesNaN("not a number"). Neither is a useful answer.Number.isFinite()istrueonly 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; } - 10
Round away floating-point errors
Computers store most decimals as very close approximations, so
0.1 + 0.2gives0.30000000000000004.toFixed(10)rounds the result to 10 decimal places, which hides the error but returns text with trailing zeros, like"0.3000000000".parseFloatturns that back into a number and drops the zeros, andString()makes it text again forcurrent.// 0.1 + 0.2 is 0.30000000000000004 in JavaScript; rounding hides that. current = String(parseFloat(result.toFixed(10))); - 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
keyscontainer hears them all.event.targetis what was actually clicked, andclosest("button")finds the key it belongs to. The key'sdatasetholds itsdata-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(); }); - 12
Let the keyboard call the same functions
A
keydownlistener on the wholedocumenthears every key press, andevent.keysays 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, sopreventDefault()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.
- Challenge 01
Add a percentage key and a key that switches between positive and negative.
- Challenge 02
Show a history of past sums that you can click to reuse.
- Challenge 03
Format long numbers with thousands separators using Intl.NumberFormat.
Learn more on the blog
Articles that go further into ideas from this project.
Finished Calculator?
Mark it done to track your progress.
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)