The definitive React number input: live formatting, full i18n, headless, accessible.
| Feature | Base UI | React Aria | Mantine | raqam |
|---|---|---|---|---|
| Live formatting while typing | ❌ blur | ❌ blur | ✅ | ✅ |
| Truly headless | ✅ | ✅ | ❌ | ✅ |
| i18n digit input (Persian ۱۲۳, Arabic ١٢٣…) | ❌ | ✅ | ❌ | ✅ |
| WAI-ARIA spinbutton | ✅ | ✅✅ | ✅✅ | |
| Bundle size | ~10 KB | ~30 KB | ~60 KB | ~1.7 KB core |
No existing package combines all four. raqam does.
npm install raqam
# or
pnpm add raqamPeer dependencies: React 18 or 19.
import { useNumberFieldState, useNumberField } from 'raqam'
import { useRef } from 'react'
function PriceInput() {
const state = useNumberFieldState({
locale: 'en-US',
formatOptions: { style: 'currency', currency: 'USD' },
minValue: 0,
defaultValue: 1234.56,
})
const inputRef = useRef(null)
const { inputProps, labelProps, incrementButtonProps, decrementButtonProps } =
useNumberField({ label: 'Price' }, state, inputRef)
return (
<div>
<label {...labelProps}>Price</label>
<button {...decrementButtonProps}>−</button>
<input ref={inputRef} {...inputProps} />
<button {...incrementButtonProps}>+</button>
</div>
)
}import { NumberField } from 'raqam'
function PriceField() {
return (
<NumberField.Root
locale="en-US"
formatOptions={{ style: 'currency', currency: 'USD' }}
defaultValue={1234.56}
minValue={0}
onValueChange={(value, { reason }) => console.log(value, reason)}
>
<NumberField.Label>Price</NumberField.Label>
<NumberField.Group>
<NumberField.Decrement>−</NumberField.Decrement>
<NumberField.Input />
<NumberField.Increment>+</NumberField.Increment>
</NumberField.Group>
<NumberField.Description>Enter the product price</NumberField.Description>
<NumberField.ErrorMessage />
</NumberField.Root>
)
}import { presets, NumberField } from 'raqam'
<NumberField.Root formatOptions={presets.currency('USD')} /> // $1,234.56
<NumberField.Root formatOptions={presets.accounting('USD')} /> // (1,234.56)
<NumberField.Root formatOptions={presets.percent} /> // 12.3%
<NumberField.Root formatOptions={presets.compact} /> // 1.2K
<NumberField.Root formatOptions={presets.scientific} /> // 1.23E3
<NumberField.Root formatOptions={presets.integer} /> // 1,234
<NumberField.Root formatOptions={presets.financial} fixedDecimalScale /> // 1,234.00
<NumberField.Root formatOptions={presets.unit('kilometer-per-hour')} /> // 120 km/hPersian input with native digits — just import the plugin and set the locale:
import 'raqam/locales/fa' // registers ۰–۹ digit normalization (< 200 B)
import { NumberField } from 'raqam'
<NumberField.Root
locale="fa-IR"
formatOptions={{ style: 'currency', currency: 'IRR' }}
suffix=" تومان"
/>
// user types ۱۲۳۴, raqam parses and formats it correctly in real-timeSupported scripts: 🇮🇷 Persian fa, 🇸🇦 Arabic ar, 🇧🇩 Bengali bn, 🇮🇳 Hindi hi, 🇹🇭 Thai th. RTL is auto-detected and handled.
<NumberField.Root
minValue={0}
validate={(value) => {
if (value === null) return 'Required'
if (value % 2 !== 0) return 'Must be an even number'
return true
}}
>
<NumberField.Input />
<NumberField.ErrorMessage /> {/* auto-renders the validate() error string */}
</NumberField.Root>import { useNumberFieldFormat } from 'raqam'
function PriceDisplay({ price }: { price: number }) {
const formatted = useNumberFieldFormat(price, {
locale: 'en-US',
formatOptions: { style: 'currency', currency: 'USD' },
})
return <span>{formatted}</span> // "$1,234.56"
}Works in React Server Components too via raqam/server:
import { createFormatter } from 'raqam/server' // zero React deps
const formatter = createFormatter({
locale: 'en-US',
formatOptions: { style: 'currency', currency: 'USD' },
})
const displayPrice = formatter.format(1234.56) // "$1,234.56"<NumberField.Root defaultValue={50} minValue={0} maxValue={100}>
<NumberField.ScrubArea direction="horizontal" pixelSensitivity={2}>
<NumberField.Label>Opacity</NumberField.Label>
<NumberField.ScrubAreaCursor>⟺</NumberField.ScrubAreaCursor>
</NumberField.ScrubArea>
<NumberField.Input />
</NumberField.Root>Uses the Pointer Lock API so the cursor never hits the screen edge during drag.
/* All state-based styling — no JS needed */
[data-focused] { outline: 2px solid blue; }
[data-invalid] { border-color: red; }
[data-disabled] { opacity: 0.5; }
[data-readonly] { background: #f5f5f5; }
[data-rtl] { /* RTL-specific overrides */ }
[data-scrubbing] { cursor: ew-resize; }import { Controller } from 'react-hook-form'
import { NumberField } from 'raqam'
<Controller
name="price"
control={control}
render={({ field, fieldState }) => (
<NumberField.Root
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
validate={() => fieldState.error?.message ?? true}
>
<NumberField.Label>Price</NumberField.Label>
<NumberField.Input />
<NumberField.ErrorMessage />
</NumberField.Root>
)}
/>For financial apps that need to avoid IEEE 754 float rounding:
<NumberField.Root
onRawChange={(rawValue) => {
// rawValue is the exact string before any JS float conversion
// e.g. "0.1000000001" — feed it to your BigDecimal library
myDecimal.set(rawValue)
}}
/>Also available as state.rawValue from the hook API.
import Decimal from 'decimal.js'
<NumberField.Root
formatValue={(value) => new Decimal(value).toFixed(8)}
parseValue={(input) => {
try {
return { value: new Decimal(input).toNumber(), isIntermediate: false }
} catch {
return { value: null, isIntermediate: input.endsWith('.') }
}
}}
/>State management hook — returns NumberFieldState.
| Prop | Type | Default | Description |
|---|---|---|---|
value |
number | null |
— | Controlled value |
defaultValue |
number | null |
— | Uncontrolled default |
onChange |
(value: number | null) => void |
— | Fires whenever the parsed numeric value changes |
onRawChange |
(raw: string | null) => void |
— | Fires with raw unformatted string |
locale |
string |
browser | BCP 47 locale tag |
formatOptions |
Intl.NumberFormatOptions |
{} |
Full Intl options |
minValue |
number |
— | Minimum value |
maxValue |
number |
— | Maximum value |
step |
number |
1 |
Arrow key step |
largeStep |
number |
step × 10 |
Shift+Arrow step |
smallStep |
number |
step × 0.1 |
Ctrl/Meta+Arrow step |
clampBehavior |
"blur" | "strict" | "none" |
"blur" |
When to clamp to min/max |
allowNegative |
boolean |
true |
Allow negative values |
allowDecimal |
boolean |
true |
Allow decimal values |
fixedDecimalScale |
boolean |
false |
Always show max decimal places |
allowOutOfRange |
boolean |
false |
Skip clamping (server-side validation) |
validate |
(v: number | null) => boolean | string | null |
— | Custom validation |
prefix |
string |
— | String prefix (e.g. "$") |
suffix |
string |
— | String suffix (e.g. " تومان") |
disabled |
boolean |
false |
Disable the field |
readOnly |
boolean |
false |
Read-only mode |
Behavior hook — returns NumberFieldAria prop objects for each element.
Additional props beyond state options:
| Prop | Type | Default | Description |
|---|---|---|---|
allowMouseWheel |
boolean |
false |
Mouse wheel to increment/decrement |
copyBehavior |
"formatted" | "raw" | "number" |
"formatted" |
Clipboard content on copy |
stepHoldDelay |
number |
400 |
Press-and-hold initial delay (ms) |
stepHoldInterval |
number |
200 |
Press-and-hold repeat interval (ms) |
formatValue |
(value: number) => string |
— | Custom format function |
parseValue |
(input: string) => { value: number | null; isIntermediate: boolean } |
— | Custom parse function |
| Prop | Type | Description |
|---|---|---|
onValueChange |
(value, { reason, formattedValue }) => void |
Fires with change reason |
Display-only formatting hook. Returns a formatted string. Zero state overhead — safe in RSC via raqam/server.
| Component | Description |
|---|---|
Root |
Context provider + state orchestration |
Label |
<label> with correct htmlFor wiring |
Group |
<div role="group"> for input + buttons |
Input |
<input type="text" role="spinbutton"> with live formatting |
Increment |
Increment button with press-and-hold acceleration |
Decrement |
Decrement button with press-and-hold acceleration |
HiddenInput |
Hidden <input> for native FormData submission |
ScrubArea |
Pointer Lock drag-to-adjust area |
ScrubAreaCursor |
Custom cursor rendered during pointer lock |
Description |
Help text linked via aria-describedby |
ErrorMessage |
Error display with role="alert" |
Formatted |
Read-only formatted value display span |
Every component accepts a render prop for element replacement:
<NumberField.Increment render={<MyIconButton />}>▲</NumberField.Increment>
// or with state access:
<NumberField.Increment render={(props, state) => (
<MyBtn disabled={!state.canIncrement} {...props} />
)} />Actual sizes (brotli compressed):
| Entry | Size |
|---|---|
raqam/core |
~1.7 KB |
raqam (hooks + components) |
~7 KB |
raqam/react |
~6.8 KB |
raqam/locales/fa |
~200 B |