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"andmax="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-labelon 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.isFiniterejectsNaN(empty input),Infinity(paste mishaps), and any other garbage. Falls back to 1. -
Math.floorstrips 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"withinputmode="numeric"(or"decimal"if you allow decimals). Mobile keyboards matter. -
Never trust
min/maxalone. Clamp in JS too. -
tabular-numsin 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
- A quantity input is HTML, CSS, and about ten lines of validation.
- Clamp, floor, and fall back to 1. Don't trust the typed value.
- Use
Intl.NumberFormatfor any user-visible price. - 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.