How to Build a Dual-State Calculator in a Chrome Side Panel
A calculator looks like a small programming exercise until it has to behave like a real tool. The arithmetic is the easy part. The difficult parts are state, keyboard focus, percentage rules, decimal precision, localization, persistence, and recovery after invalid input.
I ran into all of those details while building a calculator for Chrome's side panel. I also wanted something ordinary calculators do not provide: two independent calculation areas that remain visible at the same time. That requirement turned a tiny interface into a useful state-management case study.
This article explains the design decisions behind that implementation. The examples use plain JavaScript and Manifest V3, but the same ideas apply to small React, Vue, or desktop calculator projects.
Why two calculators need two complete states
The original problem was not a lack of calculator apps. It was losing the first number while working out the second one.
Imagine comparing two shopping carts, checking a quoted price against a budget, or calculating a quantity while keeping a percentage result visible. A single calculator forces you to copy a value somewhere else or trust your memory.
The obvious UI is two displays, A and B. The important implementation detail is that each display needs a complete calculator state, not just a different text value.

function createCalculatorState() {
return {
display: "0",
accumulator: null,
pendingOperator: null,
waitingForOperand: false,
error: null,
};
}
const calculators = {
A: createCalculatorState(),
B: createCalculatorState(),
};
let activeCalculator = "A";
If pendingOperator or waitingForOperand is shared, entering an operator in A can silently change what the next digit means in B. Those bugs are difficult to see because each display can still look correct until the equals key is pressed.
The rule that kept the design manageable was simple:
Switching calculators changes the active state object. It does not copy, reset, or reinterpret either calculator.
This makes A and B behave like two physical calculators placed next to each other.
Use a small state machine instead of evaluating strings
It is tempting to collect every button press into a string and pass it to eval(). That creates security and parsing problems, and it often produces behavior that does not match a handheld calculator.
The extension uses immediate execution. Operations run from left to right as they are entered:
2 + 3 × 4 = 20
That is different from algebraic precedence, where the result would be 14. Neither rule is universally correct for a calculator; the important thing is to choose one, document it, and test it.
A small operation function is enough:
function calculate(left, right, operator) {
switch (operator) {
case "+": return left + right;
case "-": return left - right;
case "*": return left * right;
case "/":
if (right === 0) throw new Error("Division by zero");
return left / right;
default: return right;
}
}
Button handlers should translate input into state transitions: append a digit, select an operator, calculate a result, clear the active state, or move focus. Keeping those transitions separate from rendering makes the engine testable without Chrome or a browser window.
Percentage behavior depends on the pending operator
The percent key is a classic source of hidden requirements. Users generally expect these two expressions to mean different things:
100 + 10 % = 110
50 × 20 % = 10
For addition and subtraction, the percentage is relative to the accumulated value. For multiplication and division, it is simply divided by 100.
function percentageValue(state, currentValue) {
if (
state.accumulator !== null &&
(state.pendingOperator === "+" || state.pendingOperator === "-")
) {
return state.accumulator * currentValue / 100;
}
return currentValue / 100;
}
This is a good example of why a calculator needs explicit state. The meaning of 10 % cannot be determined from the number 10 alone; it depends on the operation waiting to be completed.
Do not show raw floating-point artifacts
JavaScript uses binary floating-point numbers. As a result, this familiar expression is not represented exactly:
0.1 + 0.2 // 0.30000000000000004
A calculator should not expose that implementation detail to the user. At the same time, blindly calling toFixed(2) would destroy useful precision.
For this project, results are rounded to a controlled number of significant digits before formatting:
function normalizeResult(value) {
if (!Number.isFinite(value)) {
throw new Error("Invalid result");
}
return Number.parseFloat(value.toPrecision(12));
}
Twelve significant digits are enough for the calculator's everyday money and quantity use cases while removing common floating-point noise. Scientific notation is used only for values that are too large or too small to remain readable in the side panel.
For financial software, accounting, or measurements that require guaranteed decimal precision, use decimal arithmetic or integer minor units instead. Display rounding is a usability choice, not a replacement for a domain-appropriate number model.
Store canonical numbers and localize only the display
An English browser may display 1,234.5, while a German browser may display 1.234,5. Mixing localized strings with arithmetic creates parsing bugs.
The safer boundary is:
- Keep the internal value in a canonical JavaScript number or canonical numeric string.
- Use locale-aware formatting only when rendering.
- Convert user input deliberately instead of passing a formatted display string back into the engine.
function formatNumber(value, locale = navigator.language) {
return new Intl.NumberFormat(locale, {
maximumSignificantDigits: 12,
useGrouping: true,
}).format(value);
}
This separation also makes translation easier. UI text belongs in Chrome's _locales/<language>/messages.json, while number separators come from Intl.NumberFormat. Translation files should not attempt to define decimal punctuation themselves.
Keyboard input needs an explicit focus model
Two calculators create an ambiguity that mouse-only prototypes often miss: where should a keyboard digit go?
The implementation treats the active calculator as a first-class part of application state. Clicking A or B changes the active target. The Tab key switches between them. Other keys always go to the active target:
| Key | Action |
|---|---|
0–9, . |
Enter a number |
+, -, *, / |
Select an operation |
Enter, = |
Calculate |
Backspace |
Remove the last digit |
Escape, Delete |
Clear the active calculator |
Tab |
Switch between A and B |
The active display also needs a visible focus indicator. Keyboard behavior that works but cannot be seen is still an accessibility problem.
The side panel changes the layout constraints
A Chrome side panel is narrow, vertically oriented, and often stays open while the user navigates between tabs. That makes it a good home for a calculator, but it changes the layout priorities.
The two current values remain pinned near the top. The keypad and history can switch in the lower section. This keeps the comparison visible even when the calculation history becomes long.
The extension only needs two permissions:
{
"permissions": ["sidePanel", "storage"],
"side_panel": {
"default_path": "sidepanel.html"
}
}
sidePanel provides the interface surface. storage preserves calculator A, calculator B, history, and theme settings. There is no reason for a calculator to request browsing history, tabs, network access, or host permissions.
Keeping the permission list small is both a privacy feature and a design check. If a local calculator suddenly needs access to every website, its architecture deserves another look.
Persist state after transitions, not on a timer
The side panel can close and reopen independently of the current page. Users expect both calculators to return exactly as they left them.
Persist the state after meaningful transitions:
async function saveState() {
await chrome.storage.local.set({
calculators,
activeCalculator,
history,
});
}
Saving after every state transition is simple for a small local object. A timer-based save introduces a race where the panel can close before the last change is stored.
Stored data should also be validated when it is loaded. Extensions survive upgrades, and an older saved structure may not match the current code. Merge saved values into defaults instead of assuming every property exists.

Test the engine separately from Chrome
The arithmetic engine does not need the DOM, Chrome APIs, or a side panel. Keeping it separate makes fast command-line tests possible.
The project tests sequences rather than isolated arithmetic functions:
assertSequence(["2", "+", "3", "*", "4", "="], "20");
assertSequence(["0", ".", "1", "+", "0", ".", "2", "="], "0.3");
assertSequence(["1", "0", "0", "+", "1", "0", "%", "="], "110");
assertSequence(["5", "0", "*", "2", "0", "%", "="], "10");
Sequence tests catch state bugs that calculate(2, 3, "+") cannot detect. The current engine suite covers normal operations, repeated operators, percentage rules, decimals, division by zero, display limits, and the 00/000 money-entry shortcuts.
Browser-level tests are still useful for keyboard focus and persistence, but they should not be the first line of defense for arithmetic behavior.
What this small project taught me
The most useful lesson was not about arithmetic. It was about boundaries:
- each calculator owns a complete state;
- input changes state, rendering only displays it;
- canonical numbers stay separate from localized text;
- Chrome-specific code stays outside the arithmetic engine;
- persistence happens after defined transitions;
- tests describe real key sequences, not only helper functions.
Those boundaries are what make a small extension reliable. They also make it easier to add history, themes, localization, and keyboard control without turning the calculator into a collection of special cases.
If you are building your own side-panel tool, start by deciding what must remain visible while the user browses and what state must survive when the panel closes. The API call that opens the panel is the easy part. Designing the state so that the tool behaves predictably is where the real work begins.
Comments
Post a Comment