Raqam
Guides

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:

  1. Normalize digits — non-Latin digits (Persian ۱۲۳, Arabic ١٢٣, …) are mapped to ASCII via the registered locale plugins.
  2. Apply constraints — characters disallowed by allowNegative / allowDecimal are dropped as you type, so the field never shows a string that gets wiped on blur. A stray - or . simply does nothing.
  3. Parse with the locale-aware parser (separators are read from Intl.NumberFormat, never hardcoded).
  4. Reformat — a valid, complete value is re-rendered with grouping; an intermediate value (see below) is kept exactly as typed.
  5. 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 typeDisplay staysnumberValue
1.1.1
1.01.01
12.5012.5012.5
.5.50.5
--null
-0-00

On blur (commit) these snap to the canonical formatted form (1, 12.5012.5 if the format allows, .50.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/commitliveFormat 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 fractionIntl.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-DE1.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,567 without the comma "blocking" deletion.
  • Trailing affordances — Backspace at the end of 50% or 12 kg deletes 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:

  1. Strips common currency symbols ($ € £ ¥ ₹ ₺ ₽ ﷼ ฿ ₩ ¢ ₦ ₨ ₪ ₫ ₱).
  2. Normalizes non-Latin digits.
  3. Honors allowNegative / allowDecimal.
  4. Parses scientific / compact notation (1e3, 1.23E4, 1.5K, 3.4M, 2 billion).
  5. Parses accounting parentheses — (1,234.56) becomes -1234.56.
  6. 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:

ValueCopy / 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:

clampBehaviorBehavior
"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 (11.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.00

Turning 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:

ReasonFires 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.

On this page