Dividing a restaurant bill is easy until the cents do not divide evenly, or a group wants every person to pay a whole currency unit. I built the tip splitter with two separate totals: the mathematically exact split and an optional round-up split. That keeps the rounding decision visible instead of hiding a mismatch in the final number.
Normalize people before dividing
The calculator clamps the number of diners:
function changePeople(delta) {
const next = (Number(people.value) || 1) + delta;
people.value = Math.max(1, Math.min(99, next));
}
The computed calculation applies similar protection even if a value arrives from a pasted form field:
const b = Number(bill.value) || 0;
const p = Math.max(0, Number(tipPercent.value) || 0);
const n = Math.max(1, Math.floor(Number(people.value) || 1));
if (b <= 0) return null;
The Math.floor prevents a fractional person count, and the lower bound avoids division by zero. A zero bill returns null, which lets the template show an empty state rather than pretending that a blank form is a valid split.
Calculate tip and total independently
The core values are intentionally boring:
const tipAmount = (b * p) / 100;
const total = b + tipAmount;
let perPerson = total / n;
let rounding = 0;
let chargedTotal = total;
Keeping tipAmount and total separate matters when the interface displays a breakdown. A reader can audit whether a percentage was applied to the bill, and the program does not lose the original total just because the display later rounds numbers with toLocaleString.
Round up the person, then explain the difference
The optional policy is:
if (roundUp.value) {
perPerson = Math.ceil(perPerson);
chargedTotal = perPerson * n;
rounding = chargedTotal - total;
}
This is not “round each person independently.” It rounds the shared per-person charge upward, then recomputes the amount collected. rounding is the extra money created by that policy, so the result can say where the difference went. With a total of 100 divided among three people, a whole-unit per-person charge collects 102, not 100.
The formatting helper keeps calculation and presentation apart:
function fmt(val) {
if (val === null || val === undefined || isNaN(val)) return "—";
return Number(val).toLocaleString(undefined, {
minimumFractionDigits: 0,
maximumFractionDigits: 2,
});
}
The locale is left to the browser, while the arithmetic stays in ordinary JavaScript numbers. That is convenient for a small tool, but it is also a limitation: binary floating-point can produce tiny internal fractions, and currencies with special minor-unit rules are not modeled. The calculator does not account for tax, per-item ordering, unequal shares, service charges, or a tip base different from the entered bill.
The maximum of 99 people is another deliberate UI boundary, not a mathematical one. Large events need a different allocation model, especially when some attendees pay only for selected items.
The result should expose the policy, not just the answer
I intentionally return both total and chargedTotal. When round-up is disabled, they are equal and rounding is zero. When it is enabled, chargedTotal explains why multiplying the displayed per-person number back by the number of people no longer matches the exact bill. That makes the UI suitable for a real conversation at the table: “Do we want to split exact cents, or should everyone pay a whole unit?”
The presets [10, 15, 18, 20] are convenience values for the tip percentage, not a claim that one percentage is correct. The calculator does not infer tax or service fees, and it does not decide whether the tip should be calculated before or after tax. Those choices belong in the entered bill and the user’s local custom.
For unequal groups, the next feature would not be another rounding flag. It would be an item-level allocation model, because “three people” is not enough information when one person ordered a much more expensive meal. That is a good example of a simple tool’s boundary: making equal splitting precise does not make it fair for every group.
Even with that boundary, separating arithmetic from display is worthwhile. The computed object is small, testable, and easy to inspect, while fmt can change locale presentation without changing who owes what.
The number of people is also changed through a dedicated helper rather than trusting a stepper component to enforce its own bounds. That matters for keyboard input and pasted values, where browser controls can be bypassed. The calculation repeats the lower-bound check, so the UI remains safe even if the state is changed by another component later.
That duplication is intentional validation at the calculation boundary. Form constraints improve the experience, but computed financial output should not depend on a particular input widget behaving perfectly.
It also keeps the result predictable when the bill is edited rapidly.
One subtle benefit of returning rounding explicitly is that the interface can explain a surplus without pretending it is part of the tip. The tip remains tipAmount, the exact bill remains total, and the whole-unit collection is chargedTotal. Those names make it much harder to accidentally display the rounded charge as if it were the restaurant’s original total.
I would keep this policy visible in any test cases too. A normal split should satisfy perPerson * people === total up to floating-point precision, while a round-up split should satisfy perPerson * people === chargedTotal and report the difference in rounding. Testing those relationships is more useful than testing only one sample bill.
I turned this explicit rounding model into a small free tool: Tip & Bill Split Calculator.
Top comments (0)