Formatting & Input Behavior
How raqam formats while you type — the cursor algorithm, intermediate states, paste/copy, smart editing, clamping, and notation handling.
This page explains what actually happens between a keystroke and the formatted value you see. raqam's headline feature is live, cursor-safe formatting: the field reformats on every keystroke without the caret ever jumping to the wrong place or the value being corrupted mid-edit.
The typing pipeline
On every input event the field runs the same sequence:
- Normalize digits — non-Latin digits (Persian
۱۲۳, Arabic١٢٣, …) are mapped to ASCII via the registered locale plugins. - Apply constraints — characters disallowed by
allowNegative/allowDecimalare dropped as you type, so the field never shows a string that gets wiped on blur. A stray-or.simply does nothing. - Parse with the locale-aware parser (separators are read from
Intl.NumberFormat, never hardcoded). - Reformat — a valid, complete value is re-rendered with grouping; an intermediate value (see below) is kept exactly as typed.
- Restore the caret — the new caret position is computed from the old string, old caret, and new formatted string, then restored synchronously after React commits, so the cursor stays put even as separators shift.
The caret math is exposed for custom pipelines as
getCaretBoundary and computeNewCursorPosition.
Intermediate states
Some inputs are valid but incomplete — reformatting them mid-typing would fight the user. raqam detects these and leaves the display untouched (while still updating the numeric value) until you commit:
| You type | Display stays | numberValue |
|---|---|---|
1. | 1. | 1 |
1.0 | 1.0 | 1 |
12.50 | 12.50 | 12.5 |
.5 | .5 | 0.5 |
- | - | null |
-0 | -0 | 0 |
On blur (commit) these snap to the canonical formatted form (1, 12.50 →
12.5 if the format allows, .5 → 0.5, a lone - → empty). The integer part
still groups live even while a trailing decimal is in progress — inserting digits
into $12.50 correctly shows $9,912.50.
Need the unformatted, precision-preserving numeric string (grouping /
currency / prefix / suffix stripped, locale decimal normalized to ., typed
trailing zeros kept)? Read state.rawValue or pass onRawChange. See
Financial patterns.
Notation that formats on blur
Compact (2.5K), scientific (1.23E4), and engineering notation produce
strings whose suffix/exponent characters collide with continued typing. For
these formatOptions.notation values raqam keeps your raw digits live and only
formats on blur/commit — liveFormat is effectively forced off:
<NumberField.Root formatOptions={{ notation: "compact" }} defaultValue={1500} />
// Shows "1.5K". Focus + type "2500" → you see raw "2500" → blur → "2.5K".Because these formats are not reversible by re-parsing, raqam tracks the exact numeric value internally rather than reading it back from the display.
Percent fields
A percent field stores the fraction — Intl.NumberFormat multiplies by 100
on display — so typing 50 means 50%, i.e. a value of 0.5:
<NumberField.Root formatOptions={{ style: "percent" }} defaultValue={0.42} />
// Displays "42%". onChange fires with 0.42, not 42.While typing, the live formatter is given extra fraction headroom so a value
like 12.5% is never rounded out from under your cursor; commit() rounds to
the format's configured scale on blur.
Decimal separator handling
raqam reads the locale's decimal and grouping separators from
Intl.NumberFormat. Two conveniences smooth over keyboard differences:
- Latin keyboards in non-Latin locales — in
ar/fa(decimal separator٫) a typed ASCII.that sits between digits is mapped onto the locale separator, so the value parses and the caret stays correct. A.inside a currency symbol (e.g. Arabicج.م.) is left alone. .as a grouping separator (e.g.de-DE→1.234,56) — the ASCII.is treated as grouping, not decimal, and the comma is the decimal key.
Smart editing
Smart backspace
Two cases the browser would otherwise mishandle:
- Grouping separators — pressing Backspace right after a thousands separator
deletes the separator and the digit before it, then re-groups. You can
backspace through
1,234,567without the comma "blocking" deletion. - Trailing affordances — Backspace at the end of
50%or12 kgdeletes the last digit (the%/suffix would otherwise be instantly re-appended, making the keypress appear to do nothing).
Smart decimal
Typing the decimal separator when one already exists doesn't insert a duplicate — it moves the caret to just after the existing separator. This drives the fixed-scale money pattern:
"1.00" → press "." → caret jumps after the "." → type "5" → "1.50"Paste
Pasting is more permissive than typing, so values copied from spreadsheets and dashboards round-trip. On paste raqam, in order:
- Strips common currency symbols (
$ € £ ¥ ₹ ₺ ₽ ﷼ ฿ ₩ ¢ ₦ ₨ ₪ ₫ ₱). - Normalizes non-Latin digits.
- Honors
allowNegative/allowDecimal. - Parses scientific / compact notation (
1e3,1.23E4,1.5K,3.4M,2 billion). - Parses accounting parentheses —
(1,234.56)becomes-1234.56. - Falls back to stripping everything except digits, the locale decimal separator, and the minus sign.
If nothing parses, the paste is silently discarded rather than inserting garbage.
Copy & cut
copyBehavior controls what lands on the clipboard:
| Value | Copy / Cut produces |
|---|---|
"formatted" (default) | Browser default — the selected, formatted text |
"raw" | String(numberValue) — a plain ASCII number, e.g. 1234.56 |
"number" | Alias of "raw" |
"raw"/"number" are handy when the value is consumed by another program that
expects an unformatted number.
Mouse wheel
Opt in with allowMouseWheel. The wheel only nudges the value while the input
is focused, and the handler is attached as a non-passive native listener
so it can preventDefault() and stop the page from scrolling.
<NumberField.Root allowMouseWheel defaultValue={0} step={1} />Clamping & ranges
minValue / maxValue define the range; clampBehavior decides when the
value is forced into it:
clampBehavior | Behavior |
|---|---|
"blur" (default) | Type freely; clamp to range on blur / commit. |
"strict" | Reject keystrokes that would push the value out of range. |
"none" | Never clamp automatically (steppers still respect the range). |
Set allowOutOfRange to keep an out-of-range value as typed and committed
— useful when the server is the source of truth. The field then exposes
aria-invalid / data-invalid instead of snapping the value back.
// Server validates; UI shows the value as invalid but doesn't rewrite it.
<NumberField.Root minValue={1} maxValue={5} allowOutOfRange>
<NumberField.Input />
</NumberField.Root>Fixed decimal scale
fixedDecimalScale forces trailing zeros (1 → 1.00) — but it only takes
effect when a maximumFractionDigits is in play, either explicitly or via the
format. Pair it with maximumFractionDigits (or a format like
presets.financial that already sets it):
<NumberField.Root
formatOptions={{ style: "currency", currency: "USD" }}
fixedDecimalScale
maximumFractionDigits={2}
/>
// Always shows two decimals: $0.00, $1.50, $1,234.00Turning live formatting off
liveFormat={false} passes normalized digits straight through with no
reformatting until blur. This is mainly useful for IME/CJK workflows where
partial composition should not be reformatted. During an active IME composition
raqam already suspends formatting automatically and runs a full format cycle on
compositionend.
Change reasons
Every value change carries a reason, surfaced through
onValueChange:
| Reason | Fires when |
|---|---|
"input" | The user types or edits. |
"clear" | An edit empties the field. |
"paste" | Content is pasted. |
"keyboard" | Arrow keys, Page Up/Down, Home/End change the value. |
"increment" / "decrement" | A stepper button (incl. press-and-hold). |
"wheel" | Mouse-wheel nudge. |
"scrub" | A ScrubArea drag. |
"blur" | The value settles on blur / Enter. |
For "only when the value settles" semantics, prefer
onValueCommitted, which fires once on blur
(reason: "blur") or Enter (reason: "keyboard") with the final value.