raqam
v0.2.3
Published
The definitive React number input: live formatting, full i18n, headless, accessible
Maintainers
Readme
raqam 🔢
The definitive React number input: live formatting, full i18n, headless, accessible.
✨ Why raqam?
| 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.
📦 Installation
npm install raqam
# or
pnpm add raqamPeer dependencies: React 18 or 19.
🚀 Quick start
Hook API
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>
)
}Headless Component API
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>
)
}🎨 Format presets
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/h🌍 Locales & i18n
Persian 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.
✅ Custom validation
<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>👁️ Display-only formatting
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"🖱️ ScrubArea (drag to change value)
<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.
💄 CSS styling with data attributes
/* 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; }🔗 react-hook-form integration
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>
)}
/>⚡ Arbitrary-precision string mode
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.
🔧 Custom formatter / parser
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('.') }
}
}}
/>📐 API Reference
useNumberFieldState(options)
State management hook — returns NumberFieldState.
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| value | number \| null | — | Controlled value |
| defaultValue | number | — | Uncontrolled default |
| onChange | (value: number \| null) => void | — | Fires on every change |
| 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 |
useNumberField(props, state, inputRef)
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) => ParseResult | — | Custom parse function |
NumberField.Root extra props
| Prop | Type | Description |
|------|------|-------------|
| onValueChange | (value, { reason, formattedValue }) => void | Fires with change reason |
| onValueCommitted | (value, { reason }) => void | Fires only on blur/Enter |
useNumberFieldFormat(value, options)
Display-only formatting hook. Returns a formatted string. Zero state overhead — safe in RSC via raqam/server.
NumberField.* components
| 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} />
)} />📦 Bundle size
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 |
