DEV Community

Joe Lin for BeGoodTool.com

Posted on

Why +20% and -20% don't cancel out (and why this calculator needs four formulas)

I used to think a percentage calculator was one of those embarrassingly simple tools: take a number, divide or multiply by 100 somewhere, done.

Then I sat down to build one and ran into the part people constantly blur together in their heads: "15% of 240," "36 is what percent of 240," "80 changed to 100," and "1200 with 20% off" are all percentage questions, but they are not the same operation with different labels.

That ends up shaping the whole implementation.

A good percentage calculator is really four calculators

The Vue component doesn't try to force everything through one generic percent formula. It keeps four separate reactive states and four separate computed results:

const s1Result = computed(() => {
  const a = Number(s1.a);
  const b = Number(s1.b);
  if (isNaN(a) || isNaN(b)) return null;
  return (a * b) / 100;
});

const s2Result = computed(() => {
  const a = Number(s2.a);
  const b = Number(s2.b);
  if (isNaN(a) || isNaN(b) || b === 0) return null;
  return (a / b) * 100;
});

const s3Result = computed(() => {
  const a = Number(s3.a);
  const b = Number(s3.b);
  if (isNaN(a) || isNaN(b) || a === 0) return null;
  return ((b - a) / a) * 100;
});
Enter fullscreen mode Exit fullscreen mode

That separation matters because the denominator changes the meaning.

  • A × B / 100 answers "what is B% of A?"
  • A / B × 100 answers "A is what percent of B?"
  • (B - A) / A × 100 answers "what is the percent change from A to B?"

Those look similar enough that people mentally merge them, but if you collapse them into one vague "percentage mode," users start putting the wrong number in the denominator and get a technically correct answer to the wrong question.

Why +25% and -25% don't undo each other

The third formula is the one people most often get wrong intuitively:

const s3Result = computed(() => {
  const a = Number(s3.a);
  const b = Number(s3.b);
  if (isNaN(a) || isNaN(b) || a === 0) return null;
  return ((b - a) / a) * 100;
});
Enter fullscreen mode Exit fullscreen mode

The key detail is that percent change divides by the starting value a, not by some neutral average and not by 100.

So:

  • from 80 to 100 is (100 - 80) / 80 × 100 = +25%
  • from 100 back to 80 is (80 - 100) / 100 × 100 = -20%

Same two numbers, opposite direction, different percentage magnitude.

That's why "go up 20%, then go down 20%" doesn't bring you back where you started. Percentage change is multiplicative, not additive. Once the base changes, the next percentage is measured against a different denominator.

The component even styles this as a distinct trend result instead of just another plain number:

const s3Trend = computed(() => {
  if (s3Result.value === null) return "";
  if (s3Result.value > 0) return "is-growth";
  if (s3Result.value < 0) return "is-decline";
  return "is-flat";
});
Enter fullscreen mode Exit fullscreen mode

That sounds cosmetic, but I think it reinforces the real point: this mode is about directional change, not just a percent-shaped output.

"8折" and "20% off" are not just different wording

The most interesting implementation detail in this component is that discount math changes by locale:

const isZheDiscount = computed(() => ["tw", "cn"].includes(locale.value));

const s4Final = computed(() => {
  const a = Number(s4.a) || 0;
  const b = Number(s4.b) || 0;
  if (isZheDiscount.value) {
    return a * (b / 10);
  }
  return a * (1 - b / 100);
});
Enter fullscreen mode Exit fullscreen mode

For Traditional and Simplified Chinese, the tool uses the "打幾折" model, where 8 折 means you pay 8/10 of the original price. In English and most other locales, it switches to the % off model, where 20% off means you pay 1 - 20/100.

Those are mathematically equivalent in the common case, but they are not the same input convention. If you translate only the text and keep one fixed interpretation underneath, you'll silently miscompute discounts for part of your audience.

The UI text is also built around sentence templates instead of fixed labels:

const parseTemplate = (str) => {
  if (!str) return [];
  return str
    .split(/(##A##|##B##)/)
    .filter((p) => p !== "")
    .map((p) => {
      if (p === "##A##") return { type: "A" };
      if (p === "##B##") return { type: "B" };
      return { type: "text", value: p };
    });
};
Enter fullscreen mode Exit fullscreen mode

That lets each language define its own natural word order for things like "What is B% of A?" or "Original price A, with B% off..." while still dropping the input boxes into the right places. For a percentage tool, that actually matters because the sentence structure tells the user which number is the base and which number is the rate.

The annoying gotchas are the real work

Two small bits near the bottom say a lot about the practical edge cases:

const s4Saved = computed(() => {
  const a = Number(s4.a) || 0;
  return Math.max(0, a - s4Final.value);
});

const formatNum = (num) => {
  if (num === null || num === undefined || isNaN(num) || !isFinite(num)) return "";
  const rounded = Math.round(num * 100) / 100;
  return rounded.toLocaleString(undefined, { maximumFractionDigits: 2 });
};
Enter fullscreen mode Exit fullscreen mode

First, the component rounds to two decimal places before display. That's a sane UI choice, but percentage math still lives on JavaScript floating point underneath, so values like 0.1 + 0.2 style artifacts are always lurking in the background.

Second, "you save" is clamped with Math.max(0, ...). That avoids showing a negative savings number, but it also means weird discount inputs have slightly opinionated behavior. In the Chinese discount model, entering 12 折 makes the final price 120% of the original price, yet the saved amount will stop at 0 instead of becoming negative.

There's another limitation hiding in plain sight: the tool models a single discount step, not stacked discounts. If a store says "20% off, then another 20% off," that's not 40% off; it's 0.8 × 0.8 = 0.64, so the effective discount is 36%. You'd have to apply that in two passes, because percentages act on the new base each time.

I turned this into a small free tool while cleaning up the implementation details: Percentage Calculator.


Available in other languages

Top comments (0)