Bismillâh. Quantity inputs are where e-commerce UI goes wrong quietly. The user types "-5" or "1.5" or pastes "twenty" and the page either shows nonsense or crashes the total. On top of that, every locale formats currency differently: $1,234.00 in the US, 1.234,00 € in Germany, ₺1.234,00 in Turkey, ¥1234 in Japan, with no decimals. One tiny quantity input spans both problems. Let's fix them with clamp logic and Intl.NumberFormat.

A proper quantity input

<div class="qty" role="group" aria-label="Quantity">
  <button type="button" id="minus" aria-label="Decrease">−</button>
  <input type="number" inputmode="numeric" id="qty"
         value="1" min="1" max="99" aria-label="Quantity">
  <button type="button" id="plus" aria-label="Increase">+</button>
</div>

Five deliberate details, each one a trust point:

  • type="number" for desktop keyboard arrows and native step validation.
  • inputmode="numeric" for mobile. type="number" alone shows a keyboard with dot and comma on iOS; inputmode="numeric" gives a clean number pad.
  • min="1" and max="99" are native HTML validation bounds. Browsers enforce them when the form is submitted.
  • role="group" aria-label="Quantity" groups the three controls so a screen reader announces the purpose once, not three times.
  • Separate aria-label on each button so "−" and "+" are read as "Decrease" and "Increase." Symbols are visual shorthand; screen readers need words.

Clamp, floor, validate

Browsers let users bypass min/max by typing, so JavaScript always needs to clamp:

function clamp(n, lo, hi) {
  return Math.max(lo, Math.min(hi, n))
}

function render() {
  const raw = Number(qtyInput.value)
  const qty = Number.isFinite(raw) ? clamp(Math.floor(raw), 1, 99) : 1
  qtyInput.value = String(qty)
  minusBtn.disabled = qty <= 1
  plusBtn.disabled  = qty >= 99
}

qtyInput.addEventListener('input', render)
qtyInput.addEventListener('blur', render)

Three safety nets:

  • Number.isFinite rejects NaN (empty input), Infinity (paste mishaps), and any other garbage. Falls back to 1.
  • Math.floor strips decimals. "1.5" becomes 1.
  • clamp(qty, 1, 99) guarantees the range regardless of what the user typed.

Disabling the button at 1 and the + at 99 isn't just a visual cue, it blocks the rapid-click edge case where the counter briefly flashes 0 or 100 before the clamp runs.

Intl.NumberFormat, the currency layer

The browser ships with a global-ready currency formatter. It's lazy-loaded, highly tuned, and speaks every locale the operating system knows:

new Intl.NumberFormat('en-US', {
  style: 'currency',
  currency: 'USD'
}).format(1234.5)
// → "$1,234.50"

new Intl.NumberFormat('tr-TR', {
  style: 'currency',
  currency: 'TRY'
}).format(1234.5)
// → "₺1.234,50"

new Intl.NumberFormat('ja-JP', {
  style: 'currency',
  currency: 'JPY',
  maximumFractionDigits: 0
}).format(1234.5)
// → "¥1,235"

Three locale-aware details:

  • Thousands separator differs: , in English, . in German and Turkish, narrow no-break space in French.
  • Decimal separator differs too: . in English, , in German and Turkish.
  • Symbol position differs: "$1.00" in the US, "1,00 €" in Germany. Hard-coding '$' before the number looks alien to most of the world.

Never do '$' + amount.toFixed(2). It works for American English; everywhere else it reads as a typo.

Fraction digits: the JPY gotcha

The Japanese yen has no subunit. "¥1,234.50" isn't a price; it's wrong. Intl.NumberFormat knows this by default:

new Intl.NumberFormat('ja-JP', {
  style: 'currency',
  currency: 'JPY'
}).format(1234.5)
// → "¥1,235"   (already rounded, no decimals)

You usually don't have to set maximumFractionDigits yourself, but it's a good safety net if your rates or rounding logic could introduce decimals that shouldn't be there.

Rates are someone else's problem

The demo has a RATES object with made-up numbers. In production, never ship rates from the client. Live exchange rates belong on the backend: they update frequently, they require an API key to fetch, and they affect the actual invoice. Your frontend's job is to display a value the server computed. Currencies are one of those quiet places where amateur code costs real money.

The demo's switcher is there to show the UI; always trust the checkout total from your backend response.

Tricks worth carrying

  • Always pair type="number" with inputmode="numeric" (or "decimal" if you allow decimals). Mobile keyboards matter.
  • Never trust min/max alone. Clamp in JS too.
  • tabular-nums in CSS (font-variant-numeric: tabular-nums) keeps the total column from bouncing as digits change width. Invaluable in checkout UIs.
  • Intl.NumberFormat is cached. Calling new Intl.NumberFormat(...) in a hot path is cheap, but if you really care, memoize per locale+currency key.

Takeaways

  1. A quantity input is HTML, CSS, and about ten lines of validation.
  2. Clamp, floor, and fall back to 1. Don't trust the typed value.
  3. Use Intl.NumberFormat for any user-visible price.
  4. Live exchange rates belong on the backend. The frontend displays.

Next post: "The cheapest checkout form, using only HTML5 validation."

Count honestly. Format honestly. Show honest totals, kardeşim.