` (or custom element) that displays the current formatted value as
read-only text. Useful for dual-display UIs (edit + display side by side). It
renders with `aria-hidden="true"` so screen readers announce the value once
(from the input), not twice.
```tsx
{/* Renders: "$1,234.56" */}
```
This mirrors `state.inputValue` and updates live as the user edits. For
formatting outside a `NumberField.Root`, use
[`useNumberFieldFormat`](/docs/api/use-number-field-format) instead.
***
## `NumberField.Description` [#numberfielddescription]
Renders helper text as a ``. It is automatically associated with the input
for screen readers: while a `` is mounted, the input's
`aria-describedby` points at it. The reference is wired up after mount (the same
way `aria-labelledby` is) and removed when the description unmounts, so there is
never a dangling reference. See
[Accessibility](/docs/guides/accessibility#associating-help-text).
```tsx
Enter a value between 0 and 100.
```
If you also pass `aria-describedby` on ``, your value is
**merged** with the description id (your value first), not dropped — e.g.
`aria-describedby="my-hint amount-description"`.
***
## `NumberField.ErrorMessage` [#numberfielderrormessage]
Displays validation errors. Has `role="alert"`.
If you pass no children, it renders `state.validationError` automatically —
and nothing while the field is valid:
```tsx
{/* Renders: "Must be a positive number" */}
```
Or supply your own content — but then **you own visibility**. Children render
whenever they are truthy, valid field or not, so gate them yourself with
[`useNumberFieldContext`](/docs/api/advanced-primitives) or you leave a
permanent `role="alert"` on screen:
```tsx
function CustomError() {
const { state } = useNumberFieldContext()
if (state.validationState !== "invalid") return null
return Custom error text.
}
(v !== null && v > 0 ? true : "Must be positive")}
>
```
`ErrorMessage` does not accept a `render` prop.
***
## `NumberField.HiddenInput` [#numberfieldhiddeninput]
An ` ` for HTML form submission. Its `value` is always the
raw number (no formatting). To enable it, pass `name` to `NumberField.Root`.
```tsx
```
***
## render prop [#render-prop]
The visual building blocks (`Label`, `Group`, `Input`, `Increment`,
`Decrement`, `ScrubArea`, `ScrubAreaCursor`, and `Formatted`) accept a
`render` prop for element replacement (no `asChild` peer deps required):
```tsx
}
/>
```
Use `data-focused`, `data-invalid`, `data-disabled` on `NumberField.Root`
for pure-CSS state styling — no JavaScript class toggling needed.
---
# Core Utilities
Low-level formatting, parsing, locale registration, and caret helpers from raqam/core and raqam/server.
`raqam/core` exposes the formatter/parser engine underneath the React APIs.
`raqam/server` is an alias of the same entrypoint, useful when you want to make
the server-safe intent explicit in RSC, SSR, or Edge code.
## Import [#import]
```ts
import {
createFormatter,
createParser,
normalizeDigits,
registerLocale,
getCaretBoundary,
computeNewCursorPosition,
presets,
} from "raqam/core";
// Equivalent server-focused import:
import { createFormatter } from "raqam/server";
```
## `createFormatter(options)` [#createformatteroptions]
Build a cached `Intl.NumberFormat` wrapper with optional affixes and fraction
digit overrides.
```ts
const formatter = createFormatter({
locale: "en-US",
formatOptions: { style: "currency", currency: "USD" },
maximumFractionDigits: 2,
fixedDecimalScale: true,
});
formatter.format(1234.5);
// "$1,234.50"
```
Returned API:
| Method | Description |
| ---------------------- | --------------------------------------------------------- |
| `format(value)` | Returns the formatted string. |
| `formatToParts(value)` | Returns `Intl.NumberFormatPart[]`. |
| `formatResult(value)` | Returns `{ formatted, parts }`. |
| `getLocaleInfo()` | Returns separators, minus sign, zero digit, and RTL info. |
## `createParser(options)` [#createparseroptions]
Create a locale-aware parser that accepts the same locale, affix, and
sign/decimal constraints used by the formatter.
```ts
const parser = createParser({
locale: "fa-IR",
suffix: " تومان",
});
parser.parse("۱۲۳٬۴۵۶ تومان").value;
// 123456
```
Returned API:
| Method | Description |
| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `parse(input)` | Returns `{ value, isValid, isIntermediate }`. |
| `isIntermediate(input)` | Detects incomplete-but-valid editing states like `"-"` or `"1."`. |
| `getLocaleInfo()` | Returns locale metadata used by the parser. |
| `strip(input)` | Strips formatting affordances (grouping separators, currency symbol, prefix/suffix, percent sign) from `input`, returning the bare numeric string (ASCII digits, optional leading `-`, at most one `.`). Typed trailing zeros are preserved. Returns `string`. |
## `normalizeDigits(input)` [#normalizedigitsinput]
Converts registered non-Latin digits to ASCII without touching other
characters.
```ts
normalizeDigits("۱2٣");
// "123"
```
Use this when you need normalized digits before your own validation or storage
logic.
## `registerLocale(config)` [#registerlocaleconfig]
Add support for another digit block.
```ts
// Gujarati digits ૦–૯ (U+0AE6–U+0AEF) — not a built-in block.
registerLocale({
digitBlocks: [[0x0ae6, 0x0aef]],
});
normalizeDigits("૧૨૩");
// "123"
```
Use this only for scripts not already covered by the built-ins. The built-in
blocks are Arabic-Indic, Extended Arabic-Indic (Persian), Devanagari, Bengali,
Thai, and fullwidth (U+FF10–U+FF19, emitted by full-width CJK IMEs) — these are
normalized out of the box, no `registerLocale` needed.
For Persian, Arabic, Hindi, Bengali, and Thai, prefer the ready-made modules in
`raqam/locales/*`.
## Caret helpers [#caret-helpers]
These power the live-formatting cursor behavior and are useful only when you
build your own custom input pipeline.
### `getCaretBoundary(formattedValue, localeInfo)` [#getcaretboundaryformattedvalue-localeinfo]
Returns a `boolean[]` showing which positions in a formatted string are valid
caret stops.
### `computeNewCursorPosition(oldInput, oldCursor, newFormatted, localeInfo, inputType?)` [#computenewcursorpositionoldinput-oldcursor-newformatted-localeinfo-inputtype]
Maps the caret from the pre-format string to the new formatted string after an
edit. The optional `inputType` (the native `InputEvent.inputType`, e.g.
`"deleteContentBackward"`) refines deletion handling around grouping separators.
```ts
const formatter = createFormatter({ locale: "en-US" });
const info = formatter.getLocaleInfo();
computeNewCursorPosition("1234", 4, "1,234", info);
// 5
```
## Result types [#result-types]
The formatter and parser return these shapes (all exported as types from
`raqam`, `raqam/core`, and `raqam/server`):
```ts
interface LocaleInfo {
decimalSeparator: string; // "." en-US, "," de-DE, "٫" fa-IR
groupingSeparator: string; // "," en-US, "." de-DE, "٬" fa-IR
minusSign: string; // usually "-", but locale-specific
zero: string; // "0" Latin, "۰" Persian, …
isRTL: boolean; // true for ar/he/fa/ur/…
}
interface ParseResult {
value: number | null; // parsed number, or null if empty/invalid
isValid: boolean; // true for a complete, valid number
isIntermediate: boolean; // valid-but-incomplete ("1.", "1.50", "-")
}
interface FormatResult {
formatted: string; // the full formatted string
parts: Intl.NumberFormatPart[];
}
type CaretBoundary = boolean[]; // length = formatted.length + 1
```
`isIntermediate` is what powers live formatting — see
[Formatting & Behavior → Intermediate states](/docs/guides/formatting#intermediate-states).
## Presets from the core entrypoint [#presets-from-the-core-entrypoint]
`presets` is also available from `raqam/core` and `raqam/server`, so you can
share formatting options between client and server code.
```ts
import { createFormatter, presets } from "raqam/server";
const formatter = createFormatter({
locale: "en-US",
formatOptions: presets.currency("USD"),
});
```
Reach for these APIs when you need formatting or parsing without the React
state machine. For actual inputs, prefer useNumberFieldState +
useNumberField or the NumberField components.
---
# Format Presets
Named Intl.NumberFormatOptions configurations for common number input patterns.
`presets` is a collection of named `Intl.NumberFormatOptions` objects for the
most common number formatting patterns. Pass them directly as `formatOptions`.
## Import [#import]
```ts
import { presets } from "raqam";
// or
import { presets } from "raqam/core";
```
## Usage [#usage]
```tsx
import { presets } from "raqam";
```
## Reference [#reference]
### `presets.currency(code)` [#presetscurrencycode]
```ts
presets.currency("USD")
// → { style: "currency", currency: "USD" }
```
Standard currency formatting. Shows symbol + thousands separators.
| Locale | Value | Formatted |
| ------ | ------- | ---------- |
| en-US | 1234.56 | $1,234.56 |
| de-DE | 1234.56 | 1.234,56 € |
| ja-JP | 1234 | ¥1,234 |
| fa-IR | 1234 | ۱٬۲۳۴ ﷼ |
***
### `presets.accounting(code)` [#presetsaccountingcode]
```ts
presets.accounting("USD")
// → { style: "currency", currency: "USD", currencySign: "accounting" }
```
Like `currency` but shows negative values in parentheses: `(1,234.56)`.
raqam automatically parses `(...)` back to a negative number.
***
### `presets.percent` [#presetspercent]
```ts
presets.percent
// → { style: "percent" }
```
Formats `0.42` as `"42%"`. Store values as decimals (0–1 range).
***
### `presets.compact` [#presetscompact]
```ts
presets.compact
// → { notation: "compact" }
```
Short compact notation: `1,200,000` → `"1.2M"`.
***
### `presets.compactLong` [#presetscompactlong]
```ts
presets.compactLong
// → { notation: "compact", compactDisplay: "long" }
```
Long compact notation: `1,200,000` → `"1.2 million"`.
***
### `presets.scientific` [#presetsscientific]
```ts
presets.scientific
// → { notation: "scientific" }
```
Scientific notation: `1234567` → `"1.234567E6"`.
***
### `presets.engineering` [#presetsengineering]
```ts
presets.engineering
// → { notation: "engineering" }
```
Engineering notation (exponent always a multiple of 3): `12345` → `"12.345E3"`.
***
### `presets.integer` [#presetsinteger]
```ts
presets.integer
// → { maximumFractionDigits: 0 }
```
No decimal places. `1234.5` → `"1,235"` (rounds).
***
### `presets.financial` [#presetsfinancial]
```ts
presets.financial
// → { minimumFractionDigits: 2, maximumFractionDigits: 2 }
```
Always two decimal places. Combine with `fixedDecimalScale` prop to force
`0.00` when empty.
***
### `presets.unit(unit)` [#presetsunitunit]
```ts
presets.unit("kilometer")
// → { style: "unit", unit: "kilometer" }
```
Unit formatting. Any CLDR unit identifier works: `"kilometer"`,
`"liter"`, `"celsius"`, `"kilometer-per-hour"`, etc.
## Notation presets format on blur [#notation-presets-format-on-blur]
`compact`, `compactLong`, `scientific`, and `engineering` produce strings
(`1.2K`, `1.23E4`) whose characters collide with continued typing, so raqam
keeps your raw digits live while editing and only applies the notation on blur.
This is automatic — you don't need to set `liveFormat`. See
[Formatting & Behavior → Notation](/docs/guides/formatting#notation-that-formats-on-blur).
`percent` stores the **fraction**: a displayed `42%` is a value of `0.42`, and
typing `50` yields `0.5`.
`financial` already sets `minimumFractionDigits`/`maximumFractionDigits` to 2, so
it shows two decimals on its own. Adding the `fixedDecimalScale` prop keeps the
two trailing zeros locked in even as the user edits.
## Composing with your own options [#composing-with-your-own-options]
Presets are plain objects — spread them to override:
```ts
// Compact currency (not standard but possible)
const compactCurrency = {
...presets.compact,
style: "currency" as const,
currency: "USD",
};
```
---
# useNumberField
Behavior hook — generates ARIA props, keyboard handlers, and event handlers for a number field.
`useNumberField` takes a state object from `useNumberFieldState` and returns
prop objects (ARIA attributes, event handlers) ready to spread onto your DOM
elements.
## Import [#import]
```ts
import { useNumberField } from "raqam";
// or
import { useNumberField } from "raqam/react";
```
## Signature [#signature]
```ts
function useNumberField(
props: UseNumberFieldProps,
state: NumberFieldState,
inputRef: React.RefObject
): NumberFieldAria;
```
## Props [#props]
`UseNumberFieldProps` extends `UseNumberFieldStateOptions` (all state options
are also accepted here for convenience) plus:
| Prop | Type | Default | Description |
| ------------------ | ------------------------------------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `label` | `string` | — | Visible label text; used as the `aria-label` fallback. |
| `incrementLabel` | `string` | `"Increase"` | Accessible label for the increment button (`incrementButtonProps`); set for i18n. |
| `decrementLabel` | `string` | `"Decrease"` | Accessible label for the decrement button (`decrementButtonProps`); set for i18n. |
| `id` | `string` | auto | Explicit `id` for the ` `. Auto-generated via `useId` if omitted. |
| `aria-label` | `string` | — | Accessible label when no visible label is used. |
| `aria-labelledby` | `string` | — | Points to an external label element. |
| `aria-describedby` | `string` | — | Points to a description element. Wiring `` (or any element spreading `descriptionProps`) is now automatic while it is mounted; a value you pass here is merged with it rather than required — see [Accessibility](/docs/guides/accessibility). |
| `name` | `string` | — | Enables `hiddenInputProps` for native form submission. |
| `allowMouseWheel` | `boolean` | `false` | Enables wheel-based increment/decrement. |
| `copyBehavior` | `"formatted" \| "raw" \| "number"` | `"formatted"` | What goes to the clipboard on Copy/Cut. |
| `stepHoldDelay` | `number` | `400` | Milliseconds before press-and-hold acceleration starts. |
| `stepHoldInterval` | `number` | `200` | Milliseconds between accelerated steps. |
| `onFocus` | `(e: React.FocusEvent) => void` | — | Called when the input gains focus. |
| `onBlur` | `(e: React.FocusEvent) => void` | — | Called when the input loses focus (fires after commit). |
| `onValueCommitted` | `(value, { reason }) => void` | — | Fires when the value settles — on blur (`reason: "blur"`) or Enter (`reason: "keyboard"`), after formatting + clamping. |
All other props from `UseNumberFieldStateOptions` (`minValue`, `maxValue`,
`step`, `formatOptions`, etc.) are accepted and forwarded appropriately.
> **Pass the same options to both hooks.** `useNumberField` builds its own
> formatter and parser, so it needs the same formatting-relevant options you gave
> `useNumberFieldState` (`locale`, `formatOptions`, `prefix`, `suffix`,
> `minValue`/`maxValue`, `allowNegative`, `allowDecimal`, fraction-digit
> overrides). Share one options object between them. The
> [`NumberField` components](/docs/api/components) handle this wiring for you.
## Return value — `NumberFieldAria` [#return-value--numberfieldaria]
| Key | Spread onto | Description |
| ---------------------- | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `labelProps` | `` | `htmlFor` wired to the input `id`, plus a `ref` that registers the label so `inputProps`/`groupProps` add `aria-labelledby`. Spread it as-is; don't drop the `ref`. |
| `groupProps` | wrapping `` | `role="group"`, `aria-labelledby` pointing at the label (only when a label is rendered). |
| `inputProps` | `
` | Full ARIA spinbutton, keyboard/wheel handlers, cursor logic. |
| `incrementButtonProps` | increment `
` | `aria-label="Increase"`, `disabled`, press-and-hold. |
| `decrementButtonProps` | decrement `` | `aria-label="Decrease"`, `disabled`, press-and-hold. |
| `hiddenInputProps` | ` ` | `value` = the raw number for form submission. `null` when no `name` is set. |
| `descriptionProps` | description `` | A generated `id`, plus a `ref` that registers the description so the input's `aria-describedby` auto-wires to it while it is mounted. Spread it as-is; don't drop the `ref`. |
| `errorMessageProps` | error `
` | `role="alert"`, `aria-live="polite"`. Auto-linked via the input's `aria-errormessage` when invalid. |
## Keyboard behaviour (built-in) [#keyboard-behaviour-built-in]
| Key | Action |
| -------------- | ----------------------------------------------------------------------------- |
| ↑ | Increment by `step` |
| ↓ | Decrement by `step` |
| Shift + ↑/↓ | Increment/decrement by `largeStep` |
| Ctrl/Cmd + ↑/↓ | Increment/decrement by `smallStep` |
| Page Up | Increment by `largeStep` |
| Page Down | Decrement by `largeStep` |
| Home | Jump to `minValue` |
| End | Jump to `maxValue` |
| Enter | Commit the current value (fires `onValueCommitted`) |
| Backspace | Smart deletion (deletes through grouping separators and trailing affordances) |
`Home`/`End` only act when `minValue`/`maxValue` are set. For the full story on
smart backspace, the smart-decimal caret jump, paste handling, and live
formatting, see [Formatting & Behavior](/docs/guides/formatting).
The returned `inputProps` also include `data-rtl`, `data-invalid`,
`data-disabled`, `data-readonly`, and `data-required` attributes so you can
style state directly in CSS.
## Mouse wheel [#mouse-wheel]
The wheel handler uses a **non-passive** event listener (bypassing React's
passive-by-default `onWheel`) so `preventDefault()` can stop page scroll.
## Clipboard [#clipboard]
| `copyBehavior` | Copy produces |
| ----------------------- | ------------------------------------------ |
| `"formatted"` (default) | Browser default — the selected text |
| `"raw"` | `String(numberValue)` — plain ASCII digits |
| `"number"` | Alias of `"raw"` |
## Example [#example]
```tsx
import { useRef } from "react";
import { useNumberFieldState, useNumberField } from "raqam";
function SpinnerInput({ label }: { label: string }) {
const ref = useRef(null);
const state = useNumberFieldState({ locale: "en-US", defaultValue: 0 });
const {
labelProps,
groupProps,
inputProps,
incrementButtonProps,
decrementButtonProps,
} = useNumberField({ locale: "en-US" }, state, ref);
return (
);
}
```
---
# useNumberFieldFormat
Lightweight display-only formatting hook — no input state machine overhead.
`useNumberFieldFormat` formats a number for display using `Intl.NumberFormat`.
It has zero state machine overhead — ideal for price tables, dashboards, or any
read-only numeric display.
This is a client hook (it ships with the `"use client"` directive). In React
Server Components, Edge functions, or any non-React code, use `createFormatter`
from `raqam/server` instead — see [Core utilities](/docs/api/core-utilities) and the
server-side example below.
## Import [#import]
```ts
import { useNumberFieldFormat } from "raqam";
// or
import { useNumberFieldFormat } from "raqam/react";
```
## Signature [#signature]
```ts
function useNumberFieldFormat(
value: number | null,
options?: {
locale?: string;
formatOptions?: Intl.NumberFormatOptions;
prefix?: string;
suffix?: string;
minimumFractionDigits?: number;
maximumFractionDigits?: number;
fixedDecimalScale?: boolean;
}
): string;
```
## Parameters [#parameters]
| Param | Type | Description |
| ----------------------------------------------------------------- | -------------------------- | ------------------------------------------------------------------ |
| `value` | `number \| null` | The value to format. Returns `""` for `null` or non-finite values. |
| `options.locale` | `string` | BCP 47 locale. Defaults to the current runtime locale. |
| `options.formatOptions` | `Intl.NumberFormatOptions` | Passed to `Intl.NumberFormat`. |
| `options.prefix` / `options.suffix` | `string` | Add custom affixes around the formatted value. |
| `options.minimumFractionDigits` / `options.maximumFractionDigits` | `number` | Override fraction digit behavior. |
| `options.fixedDecimalScale` | `boolean` | Forces `maximumFractionDigits` trailing zeros. |
## Return value [#return-value]
A formatted string, or `""` when `value` is `null` or non-finite (`NaN`, `Infinity`).
## Example — price table [#example--price-table]
```tsx
import { useNumberFieldFormat } from "raqam";
function PriceTag({ amount }: { amount: number }) {
const formatted = useNumberFieldFormat(amount, {
locale: "en-US",
formatOptions: { style: "currency", currency: "USD" },
});
return {formatted} ;
}
// "en-US" + USD → "$1,234.56"
// "de-DE" + EUR → "1.234,56 €"
// "fa-IR" + IRR → "۱٬۲۳۴٫۵۶ ﷼"
```
## Example — server-side use [#example--server-side-use]
For React Server Components or SSR, import from `raqam/server` (zero React dependency):
```ts
import { createFormatter } from "raqam/server";
const formatter = createFormatter({
locale: "en-US",
formatOptions: { style: "currency", currency: "USD" },
});
const html = `${formatter.format(1234.56)} `;
// "$1,234.56"
```
## Example — in a table [#example--in-a-table]
```tsx
const items = [
{ name: "MacBook Pro", price: 2499 },
{ name: "iPhone 15 Pro", price: 1199 },
{ name: "AirPods Pro", price: 249 },
];
function PriceTable() {
return (
{items.map((item) => (
))}
);
}
function PriceRow({ name, price }: { name: string; price: number }) {
const formatted = useNumberFieldFormat(price, {
locale: "en-US",
formatOptions: { style: "currency", currency: "USD" },
});
return (
{name}
{formatted}
);
}
```
## vs NumberField.Formatted [#vs-numberfieldformatted]
| | `useNumberFieldFormat` | `NumberField.Formatted` |
| ------------------- | --------------------------------- | ----------------------------- |
| Use case | Any component, standalone display | Inside a `NumberField.Root` |
| Requires state | No | Yes (reads from context) |
| Reflects user edits | No | Yes (live updates with input) |
---
# useNumberFieldState
State management hook for number fields — use when building the Hook API pattern.
`useNumberFieldState` manages all state for a number field: the formatted
display string, the underlying numeric value, validation, focus, and scrubbing.
Pair it with [`useNumberField`](/docs/api/use-number-field) to get ARIA prop objects
for your elements.
## Import [#import]
```ts
import { useNumberFieldState } from "raqam";
// or
import { useNumberFieldState } from "raqam/react";
```
## Signature [#signature]
```ts
function useNumberFieldState(
options: UseNumberFieldStateOptions
): NumberFieldState;
```
## Options [#options]
### Core [#core]
| Prop | Type | Default | Description |
| --------------- | --------------------------------- | --------------- | ------------------------------------------------- |
| `locale` | `string` | runtime default | BCP 47 locale tag. Drives `Intl.NumberFormat`. |
| `formatOptions` | `Intl.NumberFormatOptions` | `{}` | Passed to `Intl.NumberFormat`. |
| `value` | `number \| null` | — | Controlled value. |
| `defaultValue` | `number \| null` | `null` | Uncontrolled initial value. |
| `onChange` | `(value: number \| null) => void` | — | Called whenever the parsed numeric value changes. |
### Constraints [#constraints]
| Prop | Type | Default | Description |
| ----------------- | ------------------------------ | ------------ | ----------------------------------------------------------------------------------------- |
| `minValue` | `number` | — | Minimum allowed value. |
| `maxValue` | `number` | — | Maximum allowed value. |
| `step` | `number` | `1` | Normal step (↑/↓ arrow keys). |
| `largeStep` | `number` | `step × 10` | Large step (Shift+↑/↓, Page Up/Down). |
| `smallStep` | `number` | `step × 0.1` | Small step (Ctrl/Cmd+↑/↓). |
| `clampBehavior` | `"blur" \| "strict" \| "none"` | `"blur"` | When to clamp to min/max — clamp on blur, reject out-of-range keystrokes, or never. |
| `allowOutOfRange` | `boolean` | `false` | Keep out-of-range values as typed and committed; sets `aria-invalid` instead of clamping. |
| `allowNegative` | `boolean` | `true` | Allow negative values. |
| `allowDecimal` | `boolean` | `true` | Allow decimal input. |
See [Formatting & Behavior → Clamping](/docs/guides/formatting#clamping--ranges) for
how `clampBehavior` and `allowOutOfRange` interact.
### Formatting [#formatting]
| Prop | Type | Default | Description |
| ----------------------- | --------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| `maximumFractionDigits` | `number` | — | Override `formatOptions.maximumFractionDigits`. |
| `minimumFractionDigits` | `number` | — | Override `formatOptions.minimumFractionDigits`. |
| `fixedDecimalScale` | `boolean` | `false` | Always show `maximumFractionDigits` decimal places. Requires `maximumFractionDigits` (explicit or from the format) to take effect. |
| `prefix` | `string` | — | Prepend a string (e.g. `"$"`). |
| `suffix` | `string` | — | Append a string (e.g. `" kg"`). |
| `liveFormat` | `boolean` | `true` | Format on every keystroke (disable for IME locales). |
### Validation [#validation]
| Prop | Type | Default | Description |
| ---------- | --------------------------------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------- |
| `validate` | `(v: number \| null) => boolean \| string \| null \| undefined` | — | Custom validator. Return `true`/`null`/`undefined` for valid, `false` for invalid, or a `string` to set an error message. |
| `required` | `boolean` | `false` | Mark the field as required (sets `aria-required` / `data-required`). |
### Escape hatches [#escape-hatches]
| Prop | Type | Default | Description |
| ------------- | ------------------------------------------------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `formatValue` | `(v: number) => string` | — | Replace the default formatter. |
| `parseValue` | `(s: string) => { value: number \| null; isIntermediate: boolean }` | — | Replace the default parser. |
| `onRawChange` | `(raw: string \| null) => void` | — | Called with the unformatted, precision-preserving numeric string (grouping / currency / prefix / suffix stripped, locale decimal normalized to `.`, typed trailing zeros kept). For rescaling / non-invertible styles (percent, compact, scientific, unit, custom `formatValue`) it falls back to the canonical numeric string of the value. For arbitrary precision. |
### Interaction [#interaction]
| Prop | Type | Default | Description |
| ---------- | --------- | ------- | ------------------------------ |
| `disabled` | `boolean` | `false` | Disable all interaction. |
| `readOnly` | `boolean` | `false` | Allow focus, disallow editing. |
Press-and-hold timing (`stepHoldDelay`, `stepHoldInterval`) and other
behavior-only props (`copyBehavior`, `allowMouseWheel`, `name`, `label`,
`aria-*`) live on the behavior hook — see [`useNumberField`](/docs/api/use-number-field).
## Return value — `NumberFieldState` [#return-value--numberfieldstate]
| Property | Type | Description |
| ------------------------------- | -------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `inputValue` | `string` | The formatted string currently shown in the input. |
| `numberValue` | `number \| null` | The parsed numeric value (`null` when empty/invalid). |
| `rawValue` | `string \| null` | The unformatted, precision-preserving numeric string (grouping / currency / prefix / suffix stripped, locale decimal normalized to `.`, typed trailing zeros kept). For rescaling / non-invertible styles (percent, compact, scientific, unit, custom `formatValue`) it falls back to the canonical numeric string of the value. |
| `isFocused` | `boolean` | Whether the input is focused. |
| `isScrubbing` | `boolean` | Whether a scrub drag is in progress. |
| `canIncrement` | `boolean` | False when at `maxValue` or `disabled`. |
| `canDecrement` | `boolean` | False when at `minValue` or `disabled`. |
| `validationState` | `"valid" \| "invalid"` | Result of the `validate` callback plus range checks. |
| `validationError` | `string \| null` | Error message from `validate`, or `null`. |
| `increment(amount?)` | `(amount?: number) => void` | Step up by `step` (or by `amount` when given). |
| `decrement(amount?)` | `(amount?: number) => void` | Step down by `step` (or by `amount` when given). |
| `incrementToMax()` | `() => void` | Jump to `maxValue`. |
| `decrementToMin()` | `() => void` | Jump to `minValue`. |
| `setInputValue(s, knownValue?)` | `(s: string, knownValue?: number \| null) => void` | Set the display string. Pass `knownValue` when `s` is a formatted string that can't be reparsed (compact, scientific, unit). |
| `setNumberValue(n)` | `(n: number \| null) => void` | Directly set the numeric value. |
| `commit()` | `() => number \| null` | Trigger blur-time formatting and clamping; returns the committed value. |
| `setIsFocused(b)` | `(b: boolean) => void` | Sync focus state (called by `useNumberField`). |
| `setIsScrubbing(b)` | `(b: boolean) => void` | Sync scrub state (called by `useScrubArea`). |
| `options` | `UseNumberFieldStateOptions` | The resolved options object (read by `useNumberField`/`useScrubArea`). |
## Example [#example]
```tsx
import { useRef } from "react";
import { useNumberFieldState, useNumberField } from "raqam";
function MyInput() {
const ref = useRef(null);
const state = useNumberFieldState({
locale: "en-US",
formatOptions: { style: "currency", currency: "USD" },
minValue: 0,
onChange: (value) => console.log("parsed value:", value),
});
const { labelProps, inputProps } = useNumberField(
{ locale: "en-US", formatOptions: { style: "currency", currency: "USD" } },
state,
ref,
);
return (
<>
Amount
Current: {state.numberValue}
>
);
}
```
`useNumberFieldState` does NOT read from or write to the DOM. It's a pure
React state machine. Pair it with `useNumberField` for DOM integration.
If you need change metadata such as `"increment"` vs `"paste"` vs `"blur"`,
use `NumberField.Root` with `onValueChange` instead of the raw state hook.
---
# Getting Started
Install raqam and build your first number field in under five minutes.
## Installation [#installation]
npm
pnpm
yarn
bun
```bash
npm install raqam
```
```bash
pnpm add raqam
```
```bash
yarn add raqam
```
```bash
bun add raqam
```
**Peer dependencies:** React 18 or 19.
## Your first field [#your-first-field]
### Component API (recommended) [#component-api-recommended]
```tsx
import { NumberField } from "raqam";
export function QuantityInput() {
return (
Quantity
−
+
);
}
```
### Hook API (maximum control) [#hook-api-maximum-control]
```tsx
import { useRef } from "react";
import { useNumberFieldState, useNumberField } from "raqam";
export function QuantityInput() {
const inputRef = useRef(null);
// Share one options object — useNumberField builds its own formatter/parser
// and needs the same formatting-relevant options as the state hook.
const options = { locale: "en-US", defaultValue: 1, minValue: 0 };
const state = useNumberFieldState(options);
const { labelProps, groupProps, inputProps, incrementButtonProps, decrementButtonProps } =
useNumberField(options, state, inputRef);
return (
);
}
```
The `NumberField` components do this wiring for you, so reach for the Hook API
only when you need full control of the DOM. See
[`useNumberField`](/docs/api/use-number-field) for why both hooks need the same options.
## Controlled vs uncontrolled [#controlled-vs-uncontrolled]
Like all React form controls, raqam supports both patterns.
### Uncontrolled (defaultValue) [#uncontrolled-defaultvalue]
```tsx
```
### Controlled (value + onChange) [#controlled-value--onchange]
```tsx
const [value, setValue] = useState(42);
```
`onChange` receives `number | null` whenever the parsed numeric value changes.
If you need metadata about *how* the change happened, use `onValueChange` on
`NumberField.Root` for `{ reason, formattedValue }`. To react only when the value
settles (on blur or Enter), use `onValueCommitted` instead — see
[Formatting & Behavior → Change reasons](/docs/guides/formatting#change-reasons).
## Currency formatting [#currency-formatting]
```tsx
Price
−
+
```
The `formatOptions` prop accepts any `Intl.NumberFormatOptions`. Use
[`presets`](/docs/api/presets) for common configurations.
## Adding min/max/step [#adding-minmaxstep]
```tsx
```
| Key | Action |
| -------------- | --------------------------- |
| ↑ / ↓ | step (default: 1) |
| Shift + ↑/↓ | largeStep (default: 10) |
| Ctrl/Cmd + ↑/↓ | smallStep (default: 0.1) |
| Page Up/Down | largeStep |
| Home / End | jump to minValue / maxValue |
## Form integration [#form-integration]
Use `NumberField.HiddenInput` to submit the raw numeric value in an HTML form.
```tsx
```
For library-managed forms see [react-hook-form](/docs/recipes/react-hook-form) and
[Formik](/docs/recipes/formik) recipes.
## TypeScript [#typescript]
raqam is written in TypeScript. Key types:
```ts
import type {
UseNumberFieldStateOptions,
NumberFieldState,
UseNumberFieldProps,
NumberFieldAria,
ChangeReason,
} from "raqam";
```
Use `raqam/core` for server-side formatting (RSC, edge functions). It has zero
React dependency. See [Next.js guide](/docs/guides/nextjs).
---
# Accessibility
WAI-ARIA spinbutton role, keyboard navigation, and screen reader behaviour.
raqam implements the WAI-ARIA
[`spinbutton`](https://www.w3.org/WAI/ARIA/apg/patterns/spinbutton/) pattern.
All ARIA attributes are generated automatically — you don't need to add them
manually.
## ARIA attributes [#aria-attributes]
### Input element [#input-element]
| Attribute | Value | Notes |
| ------------------- | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `role` | `"spinbutton"` | Always set |
| `aria-valuenow` | `numberValue` | Current numeric value |
| `aria-valuemin` | `minValue` | Only set when `minValue` is provided |
| `aria-valuemax` | `maxValue` | Only set when `maxValue` is provided |
| `aria-valuetext` | formatted string | Localized display value for screen readers |
| `aria-invalid` | boolean `true` when invalid | Set by `validate` callback or `allowOutOfRange` |
| `aria-disabled` | `true` when disabled | |
| `aria-readonly` | `true` when readOnly | |
| `aria-required` | `true` when required | Pass `required` prop |
| `aria-labelledby` | — | Auto-wired from `` |
| `aria-describedby` | description element `id` | Auto-wired from a mounted ``, merged with any `aria-describedby` you pass on `` — see [Associating help text](#associating-help-text) |
| `aria-errormessage` | error element `id` | Auto-wired to `` when the field is invalid |
| `inputMode` | `"decimal"` | Surfaces the numeric keyboard on mobile |
| `type` | `"text"` | Always `text` (not `number`) to avoid browser UI conflicts |
| `autoComplete` | `"off"` | Prevents autocomplete interference |
### Button elements [#button-elements]
Increment and decrement buttons receive:
* `aria-label`: `"Increase"` / `"Decrease"`. For i18n, the preferred hook is the
`incrementLabel` / `decrementLabel` props on `` (defaults
`"Increase"` / `"Decrease"`). You can still override per-button by passing your
own `aria-label` on `` / `` — it
takes precedence over the default.
* `tabIndex={-1}`: Buttons are intentionally outside the Tab order
* `disabled`: When at `minValue`/`maxValue` or when the field is disabled
The scrub area is labelled via `` (default
`"Scrub to change value"`).
## Keyboard navigation [#keyboard-navigation]
| Key | Action |
| ------------ | --------------------------------------------------------- |
| ↑ | Increment by `step` |
| ↓ | Decrement by `step` |
| Shift + ↑ | Increment by `largeStep` (default `step × 10`) |
| Shift + ↓ | Decrement by `largeStep` |
| Ctrl/Cmd + ↑ | Increment by `smallStep` (default `step × 0.1`) |
| Ctrl/Cmd + ↓ | Decrement by `smallStep` |
| Page Up | Increment by `largeStep` |
| Page Down | Decrement by `largeStep` |
| Home | Jump to `minValue` (if set) |
| End | Jump to `maxValue` (if set) |
| Enter | Commit the current value (triggers formatting + clamping) |
| Tab | Move focus out of the field |
## Focus management [#focus-management]
* The **input** is the only focusable element by default
* Stepper buttons have `tabIndex={-1}` (keyboard-accessible via ↑/↓, not Tab)
* `data-focused=""` is set on the root element while the input is focused
* `onFocus`/`onBlur` prop forwarding is supported
## Validation and error messages [#validation-and-error-messages]
Use `aria-errormessage` (via `NumberField.ErrorMessage`) to announce errors:
```tsx
v === null ? "Required" : v > 0 ? true : "Must be positive"}
>
Quantity
{/* Gets role="alert" — announced on change */}
```
`NumberField.ErrorMessage` has `role="alert"` which causes screen readers to
announce the message immediately when it appears. The input is automatically
linked to it via `aria-errormessage` whenever the field is invalid.
## Associating help text [#associating-help-text]
`` is automatically associated to the input via
`aria-describedby` while it is mounted — just drop it in, no manual wiring
required:
```tsx
Quantity
Enter a value between 0 and 100.
```
If you also pass `aria-describedby` on ``, your value is
merged with the description's `id` (consumer value first), not dropped. As with
`aria-labelledby`, the wiring is applied after mount.
## Accessible label patterns [#accessible-label-patterns]
### Visible label (recommended) [#visible-label-recommended]
```tsx
Price
```
### aria-label (no visible label) [#aria-label-no-visible-label]
```tsx
```
### External label [#external-label]
```tsx
Configure price
```
## Automated testing [#automated-testing]
raqam is tested with `jest-axe` for WCAG compliance:
```ts
import { axe, toHaveNoViolations } from "jest-axe";
import { render } from "@testing-library/react";
import { NumberField } from "raqam";
expect.extend(toHaveNoViolations);
test("has no accessibility violations", async () => {
const { container } = render(
Amount
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
```
---
# 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 [#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](/docs/guides/locales).
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`](/docs/api/core-utilities#caret-helpers).
## Intermediate states [#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](/docs/recipes/financial#arbitrary-precision-crypto--scientific).
## Notation that formats on blur [#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:
```tsx
// 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 [#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`:
```tsx
// 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 [#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-editing]
### Smart backspace [#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 [#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 [#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 [#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 [#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.
```tsx
```
## Clamping & ranges [#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.
```tsx
// Server validates; UI shows the value as invalid but doesn't rewrite it.
```
## Fixed decimal scale [#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):
```tsx
// Always shows two decimals: $0.00, $1.50, $1,234.00
```
## Turning live formatting off [#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 [#change-reasons]
Every value change carries a `reason`, surfaced through
[`onValueChange`](/docs/api/components#numberfieldroot):
| 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](/docs/api/components#numberfieldscrubarea) drag. |
| `"blur"` | The value settles on blur / Enter. |
For "only when the value settles" semantics, prefer
[`onValueCommitted`](/docs/api/components#numberfieldroot), which fires once on blur
(`reason: "blur"`) or Enter (`reason: "keyboard"`) with the final value.
---
# Locales & i18n
Using raqam with non-Latin digit systems — Persian, Arabic, Bengali, Hindi, Thai.
raqam uses `Intl.NumberFormat` for all formatting and parsing. You never
hardcode separators — they're extracted dynamically from the browser's
internationalization engine.
## Basic locale switching [#basic-locale-switching]
Pass any BCP 47 locale tag to the `locale` prop:
```tsx
// German: 1.234,56
// French: 1 234,56 €
// Japanese: ¥1,234
```
Separators, currency symbols, minus signs, and digit systems are all resolved
automatically — no configuration needed.
## Non-Latin digit systems [#non-latin-digit-systems]
Five locale plugins add support for writing systems that use non-ASCII digits:
| Plugin | Script | Digits | BCP 47 tags |
| ------------------ | ------------------------------- | ---------- | ------------------------- |
| `raqam/locales/fa` | Persian (Extended Arabic-Indic) | ۰۱۲۳۴۵۶۷۸۹ | `fa`, `fa-IR`, `fa-AF` |
| `raqam/locales/ar` | Arabic-Indic | ٠١٢٣٤٥٦٧٨٩ | `ar`, `ar-EG`, `ar-SA`, … |
| `raqam/locales/hi` | Devanagari | ०१२३४५६७८९ | `hi`, `hi-IN`, `mr`, `ne` |
| `raqam/locales/bn` | Bengali | ০১২৩৪৫৬৭৮৯ | `bn`, `bn-BD`, `bn-IN` |
| `raqam/locales/th` | Thai | ๐๑๒๓๔๕๖๗๘๙ | `th`, `th-TH` |
### Installing a plugin [#installing-a-plugin]
Import the locale plugin once, anywhere in your app (it runs as a side effect):
```ts
// app/layout.tsx or src/main.tsx
import "raqam/locales/fa"; // adds Persian digit support
import "raqam/locales/ar"; // adds Arabic-Indic digit support
```
Then use the locale normally:
```tsx
```
Without the plugin, Persian `۱۲۳` typed by the user won't be normalized. With
the plugin, `۱۲۳` is accepted and internally treated as `123`.
### All plugins at once [#all-plugins-at-once]
```ts
import "raqam/locales"; // imports fa, ar, hi, bn, th
```
Each plugin is tiny — well under 200 B (e.g. `raqam/locales/fa` is 196 B, min +
brotli), enforced in CI via `.size-limit.json`.
### Locale metadata exports [#locale-metadata-exports]
If you want to build locale pickers or feature flags, the `raqam/locales`
entrypoint also re-exports the supported locale lists (the main `raqam` entry
does not):
```ts
import {
FA_LOCALE_CODES,
AR_LOCALE_CODES,
BN_LOCALE_CODES,
HI_LOCALE_CODES,
TH_LOCALE_CODES,
} from "raqam/locales";
```
These arrays are useful when you want to map a runtime locale to a matching
plugin without hardcoding the supported tags yourself.
## Lakh/crore grouping [#lakhcrore-grouping]
South Asian locales (`hi-IN`, `bn-BD`, `mr-IN`) use a different grouping
pattern than Western locales (lakhs and crores):
```
en-US: 10,000,000 (millions)
hi-IN: 1,00,00,000 (crores)
```
raqam handles this automatically via `Intl.NumberFormat`. Native digit input for
Marathi (`mr`) and Nepali (`ne`) ships in the `raqam/locales/hi` plugin.
## RTL locales [#rtl-locales]
Arabic, Persian, Hebrew, and Urdu are right-to-left. raqam automatically
detects RTL locales and applies the correct rendering. See the
[RTL guide](/docs/guides/rtl) for details.
## Custom digit blocks [#custom-digit-blocks]
If you need a digit system not covered by the built-in plugins, register it:
```ts
import { registerLocale } from "raqam/core";
// Mongolian digits: ᠐᠑᠒᠓᠔᠕᠖᠗᠘᠙ (U+1810–U+1819)
registerLocale({
digitBlocks: [[0x1810, 0x1819]],
});
```
Locale plugins are tree-shakeable. Only the plugins you import are included
in your bundle.
## Supported locale examples [#supported-locale-examples]
```tsx
const LOCALES = [
{ locale: "en-US", label: "English (US)", formatOptions: { style: "currency", currency: "USD" } },
{ locale: "de-DE", label: "German", formatOptions: { style: "currency", currency: "EUR" } },
{ locale: "fr-FR", label: "French", formatOptions: { style: "currency", currency: "EUR" } },
{ locale: "fa-IR", label: "Persian (Iran)", formatOptions: { style: "currency", currency: "IRR" } },
{ locale: "ar-EG", label: "Arabic (Egypt)", formatOptions: { style: "currency", currency: "EGP" } },
{ locale: "hi-IN", label: "Hindi (India)", formatOptions: { style: "currency", currency: "INR" } },
{ locale: "bn-BD", label: "Bengali (Bangladesh)", formatOptions: { style: "currency", currency: "BDT" } },
{ locale: "th-TH", label: "Thai", formatOptions: { style: "currency", currency: "THB" } },
{ locale: "ja-JP", label: "Japanese", formatOptions: { style: "currency", currency: "JPY" } },
{ locale: "zh-CN", label: "Chinese (Simplified)", formatOptions: { style: "currency", currency: "CNY" } },
];
```
---
# Next.js App Router
Using raqam with Next.js App Router — client components, Server Components, and edge functions.
## Client components [#client-components]
raqam is a client-side library (it uses React hooks, DOM APIs, and browser
`Intl`). All components and hooks must run in a Client Component.
```tsx
// components/price-input.tsx
"use client";
import { NumberField } from "raqam";
export function PriceInput({ value, onChange }: {
value: number | null;
onChange: (v: number | null) => void;
}) {
return (
Price
−
+
);
}
```
The React entries (`raqam` and `raqam/react`) ship with `"use client";`
prepended to their bundles, so you can import them directly in Client
Components without any extra wrapper. `raqam/core` and `raqam/server` do
**not** carry the directive — they have zero React dependency and are safe to
import from Server Components.
## Server Components — formatting only [#server-components--formatting-only]
For SSR formatting (price display, report generation, email templates), import
from `raqam/server` — it has **zero React dependency** and runs in any
server context including Edge Runtime:
```ts
// app/products/page.tsx (Server Component)
import { createFormatter } from "raqam/server";
import { presets } from "raqam/server";
const fmt = createFormatter({
locale: "en-US",
formatOptions: presets.currency("USD"),
});
export default function ProductsPage() {
const price = fmt.format(1234.56); // "$1,234.56"
return Price: {price}
;
}
```
`raqam/server` is an alias for `raqam/core`. It exports `createFormatter`,
`createParser`, `normalizeDigits`, `registerLocale`, and `presets`.
## Pattern: Server Component + Client Input [#pattern-server-component--client-input]
A common pattern: the Server Component renders the page shell and passes
formatted prices as props, while a nested Client Component handles the editable
field.
```tsx
// app/checkout/page.tsx (Server Component)
import { createFormatter } from "raqam/server";
import { CheckoutForm } from "./checkout-form";
export default async function CheckoutPage() {
const product = await getProduct();
const formatter = createFormatter({
locale: "en-US",
formatOptions: { style: "currency", currency: "USD" },
});
return (
);
}
```
```tsx
// app/checkout/checkout-form.tsx (Client Component)
"use client";
import { useState } from "react";
import { NumberField } from "raqam";
export function CheckoutForm({
productName,
defaultPrice,
formattedPrice,
}: {
productName: string;
defaultPrice: number;
formattedPrice: string;
}) {
const [price, setPrice] = useState(defaultPrice);
return (
);
}
```
## Locale plugins with App Router [#locale-plugins-with-app-router]
Import locale plugins in your root layout (Server Component imports are fine
for side-effect-only modules):
```tsx
// app/layout.tsx
import "raqam/locales/fa";
import "raqam/locales/ar";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return {children};
}
```
Locale plugins register digit blocks in a module-level array. In Next.js,
this happens once per server instance and persists across requests.
## Edge Runtime [#edge-runtime]
`raqam/server` (`raqam/core`) is Edge Runtime compatible — it uses only
`Intl.NumberFormat` which is available in all modern edge environments
(Vercel Edge, Cloudflare Workers, Deno Deploy).
```ts
// edge-function.ts
import { createFormatter } from "raqam/server";
export const runtime = "edge";
export default function handler() {
const fmt = createFormatter({ locale: "en-US" });
return new Response(fmt.format(42));
}
```
---
# RTL Support
How raqam renders right-to-left number inputs for Arabic, Persian, Hebrew, and Urdu.
RTL (right-to-left) number inputs have a subtle rendering challenge: the
**numbers themselves are always left-to-right** (digits flow 0→9 regardless of
script), but the **surrounding text and layout** flows right-to-left.
raqam handles this automatically for all RTL locales.
## Auto-detection [#auto-detection]
raqam detects RTL locales via `Intl.NumberFormat.resolvedOptions().locale`:
```ts
// Detected as RTL:
"ar", "ar-EG", "ar-SA", "ar-MA" // Arabic
"fa", "fa-IR", "fa-AF" // Persian
"he", "he-IL" // Hebrew
"ur", "ur-PK" // Urdu
```
No configuration needed. Import the locale, set the `locale` prop, and the
correct styles are applied automatically.
## What gets applied [#what-gets-applied]
For RTL locales, raqam applies two CSS properties to the input element:
```css
direction: ltr; /* numbers flow left-to-right */
text-align: right; /* text is visually right-aligned */
unicode-bidi: embed; /* correct BiDi embedding for currency */
```
This ensures:
* Typing `۱`, `۲`, `۳` inserts digits correctly from left to right
* The cursor stays in the right place
* Currency symbols (like `﷼` or `$`) appear on the correct side
## Data attribute for CSS [#data-attribute-for-css]
The input element receives `data-rtl=""` when the locale is RTL. Use this
for additional CSS customization:
```css
input[data-rtl] {
/* extra RTL-specific styles */
padding-inline-end: 12px;
}
```
`data-rtl` is set on the **input** element, not on `NumberField.Root`. Tailwind
patterns that key off the root (`group-data-[rtl]`) won't see it — target the
input directly (`data-[rtl]:` on ``).
## Persian example [#persian-example]
```tsx
import "raqam/locales/fa";
import { NumberField } from "raqam";
مبلغ {/* "Amount" in Persian */}
−
+
```
The input accepts both Persian digits (۱۲۳) and ASCII digits (123),
normalizing them to the same value internally.
## Arabic example [#arabic-example]
```tsx
import "raqam/locales/ar";
import { NumberField } from "raqam";
```
Formatted value: `٩٬٩٩٩٫٠٠ ج.م.`
## RTL layout considerations [#rtl-layout-considerations]
When embedding a number field in a right-to-left page, wrap the field in a
container with `dir="rtl"`:
```html
...
```
Don't set `dir="rtl"` on the ` ` element itself — raqam manages
`direction: ltr` on the input to keep digit order correct.
## Keyboard input [#keyboard-input]
All keyboard shortcuts work correctly in RTL mode:
* ↑/↓ increment/decrement
* Backspace over grouping separators (e.g. `٬`) skips them correctly
* Home/End jump to `minValue`/`maxValue` (when those are set)
---
# Playground
Edit runnable examples in the browser — the sandbox installs the same raqam package as npm.
Try the examples below: they run in an isolated sandbox that resolves **`raqam` from npm** (pinned to the version shown in the toolbar), so what you see matches **`npm install raqam`** in your own app.
---
# Financial App
Accounting format, fixed decimal scale, arbitrary precision, and currency conversion.
## Accounting format [#accounting-format]
Show negative balances as `(1,234.56)` instead of `-$1,234.56` — standard in
double-entry bookkeeping:
```tsx
import { presets } from "raqam";
import { NumberField } from "raqam";
Account balance
// Displays: (1,234.56) ← negative shown as parentheses
// Displays: 1,234.56 ← positive shown normally
```
raqam automatically parses `(1,234.56)` back to `-1234.56` when the user
pastes accounting-formatted values.
## Fixed decimal scale [#fixed-decimal-scale]
For monetary inputs, always show exactly two decimal places:
```tsx
// Always shows: $0.00, $1.23, $1,234.56
```
## Arbitrary precision (crypto / scientific) [#arbitrary-precision-crypto--scientific]
Use `rawValue` + `onRawChange` to capture the unformatted, precision-preserving
numeric string (grouping / currency / prefix / suffix stripped, locale decimal
normalized to `.`, typed trailing zeros kept), bypassing JavaScript
floating-point limitations:
```tsx
import { useState } from "react";
import { NumberField } from "raqam";
function CryptoInput() {
const [raw, setRaw] = useState(null);
return (
v.toFixed(8)}
parseValue={(s) => {
const n = parseFloat(s);
return {
// Return null (not NaN) for empty/unparseable input
value: s === "" || Number.isNaN(n) ? null : n,
isIntermediate: s.endsWith(".") || /\.\d*0+$/.test(s),
};
}}
onRawChange={setRaw}
>
BTC amount
{raw && Raw: {raw}
}
);
}
// Shows: 3.14159265 (8 decimal places)
// raw = "3.14159265" (exact string for big-decimal libraries)
```
Pass `raw` to a BigDecimal library (decimal.js, big.js) for precise arithmetic.
## Currency conversion display [#currency-conversion-display]
Show the converted value in real time using `NumberField.Formatted` + a read-only instance:
```tsx
import { useState } from "react";
import { NumberField, useNumberFieldFormat } from "raqam";
const USD_TO_EUR = 0.92;
function CurrencyConverter() {
const [usd, setUsd] = useState(100);
const eur = usd !== null ? usd * USD_TO_EUR : null;
const eurFormatted = useNumberFieldFormat(eur ?? 0, {
locale: "de-DE",
formatOptions: { style: "currency", currency: "EUR" },
});
return (
USD amount
≈ {eurFormatted}
);
}
```
## Validated budget range [#validated-budget-range]
```tsx
{
if (v === null) return "Budget is required";
if (v < 1000) return "Minimum budget is $1,000";
if (v > 100000) return "Maximum budget is $100,000";
if (v % 100 !== 0) return "Budget must be in $100 increments";
return true;
}}
>
Annual budget
−$100
+$100
```
## Change reason tracking [#change-reason-tracking]
Know exactly how the user changed the value — useful for analytics:
```tsx
{
analytics.track("number_changed", { value, reason, formattedValue });
// reason: "input" | "clear" | "blur" | "paste" | "keyboard" | "increment" | "decrement" | "wheel" | "scrub"
}}
>
```
---
# Formik
Integrating raqam with Formik using the useFormik hook or Field component.
## Installation [#installation]
```sh
npm install formik
```
## useFormik pattern [#useformik-pattern]
```tsx
import { useFormik } from "formik";
import { NumberField } from "raqam";
export function OrderForm() {
const formik = useFormik({
initialValues: { quantity: 1, price: null as number | null },
validate(values) {
const errors: Record = {};
if (values.quantity < 1) errors.quantity = "Must be at least 1";
if (values.price === null) errors.price = "Price is required";
else if (values.price < 0.01) errors.price = "Must be positive";
return errors;
},
onSubmit(values) {
console.log("Submitted:", values);
},
});
return (
);
}
```
## Yup schema validation [#yup-schema-validation]
```tsx
import { useFormik } from "formik";
import * as Yup from "yup";
const schema = Yup.object({
price: Yup.number()
.required("Price is required")
.min(0.01, "Must be at least $0.01")
.max(10000, "Cannot exceed $10,000"),
});
export function PriceForm() {
const formik = useFormik({
initialValues: { price: 0 },
validationSchema: schema,
onSubmit: console.log,
});
return (
);
}
```
## Tips [#tips]
* Use `setFieldValue("field", v)` (not Formik's `handleChange`) since raqam
gives you a `number | null`, not a DOM event.
* Use `setFieldTouched("field")` in `onBlur` to trigger touched state.
* Pass the Formik error message directly to `NumberField.ErrorMessage` as
children for custom error rendering.
---
# Persian E-commerce
Building number inputs for Persian-language e-commerce — toman currency, RTL, and native digit support.
This recipe shows how to build a complete Persian-language price input that:
* Accepts Persian digits (۱۲۳) and converts them automatically
* Displays the toman suffix (تومان)
* Renders correctly in RTL context
* Formats with Persian grouping separators (٬)
## Setup [#setup]
```ts
// app/layout.tsx or main.tsx
import "raqam/locales/fa"; // Register Persian digit block
```
## Basic toman input [#basic-toman-input]
```tsx
import { NumberField } from "raqam";
export function TomanInput() {
return (
قیمت {/* "Price" */}
−
{/* raqam applies direction: ltr automatically for RTL locales */}
+
);
}
```
Displays: `۱٬۲۳۴ تومان`
## Controlled with formatted display [#controlled-with-formatted-display]
```tsx
import { useState } from "react";
import { NumberField, useNumberFieldFormat } from "raqam";
export function ProductPriceEditor() {
const [price, setPrice] = useState(125000);
const formatted = useNumberFieldFormat(price, {
locale: "fa-IR",
formatOptions: {},
});
return (
قیمت محصول
−
{/* An inline `style` replaces raqam's RTL style object, so re-declare
direction: ltr here (or style the input with className instead). */}
+
قیمت نمایشدادهشده: {formatted} تومان
);
}
```
## Digit input examples [#digit-input-examples]
Persian digit normalization is transparent:
| User types | raqam stores | Displays |
| ---------------- | ------------ | -------- |
| `۱۲۳۴` (Persian) | `1234` | `۱٬۲۳۴` |
| `1234` (ASCII) | `1234` | `۱٬۲۳۴` |
| Mixed `۱2۳4` | `1234` | `۱٬۲۳۴` |
## IRR currency format [#irr-currency-format]
If you want the official rial currency symbol from `Intl.NumberFormat`:
```tsx
// Displays something like: ۰ ﷼
// The exact symbol (﷼ vs ریال) and number of fraction digits come from the
// runtime's ICU data, so output can vary across environments.
```
## Validation for price ranges [#validation-for-price-ranges]
```tsx
{
if (v === null) return "قیمت الزامی است";
if (v < 10000) return "حداقل قیمت ۱۰٬۰۰۰ تومان است";
if (v > 100000000) return "حداکثر قیمت ۱۰۰٬۰۰۰٬۰۰۰ تومان است";
return true;
}}
>
قیمت
```
## SSR with Next.js [#ssr-with-nextjs]
For server-side price display in Persian:
```ts
import { createFormatter } from "raqam/server";
const priceFormatter = createFormatter({
locale: "fa-IR",
suffix: " تومان",
});
// In a Server Component:
const displayPrice = priceFormatter.format(125000);
// "۱۲۵٬۰۰۰ تومان"
```
The `fa-IR` locale uses `٬` (Arabic thousands separator, U+066C) and `٫`
(Arabic decimal separator, U+066B) — different from the ASCII `,` and `.`.
raqam extracts these from `Intl.NumberFormat` automatically.
---
# react-hook-form
Integrating raqam with react-hook-form using the Controller pattern.
raqam works naturally with react-hook-form via the `Controller` component.
The key is: raqam is `value`/`onChange` controlled, and `Controller` supplies
exactly those.
## Installation [#installation]
```sh
npm install react-hook-form
```
## Basic Controller integration [#basic-controller-integration]
```tsx
import { useForm, Controller } from "react-hook-form";
import { NumberField } from "raqam";
type FormValues = {
price: number | null;
};
export function PriceForm() {
const {
control,
handleSubmit,
formState: { errors },
} = useForm({ defaultValues: { price: null } });
return (
);
}
```
## Using raqam's built-in validate prop [#using-raqams-built-in-validate-prop]
For simple validation, use raqam's `validate` prop directly alongside
react-hook-form — this updates `aria-invalid` and renders `NumberField.ErrorMessage`:
```tsx
(
{
if (v === null) return "Required";
if (v < 1) return "Min $1";
return true;
}}
>
Amount
)}
/>
```
## Custom validation with Zod [#custom-validation-with-zod]
```tsx
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const schema = z.object({
price: z
.number({ required_error: "Price is required" })
.min(0.01, "Must be positive")
.max(999999, "Too large"),
});
export function PriceForm() {
const { control, handleSubmit } = useForm({
resolver: zodResolver(schema),
defaultValues: { price: 0 },
});
return (
);
}
```
## Multiple currency fields [#multiple-currency-fields]
```tsx
type InvoiceForm = {
subtotal: number | null;
tax: number | null;
discount: number | null;
};
const currencyField = (name: keyof InvoiceForm, label: string) => (
(
{label}
)}
/>
);
```
`onChange` fires with `number | null`. react-hook-form's `rules.required`
checks for `null` — this works because `null` is falsy.
---
# shadcn/ui
Wrapping raqam with shadcn/ui primitives for a polished, design-system-consistent number input.
[shadcn/ui](https://ui.shadcn.com) provides styled, accessible components built
on Radix UI. Since raqam is headless, it integrates cleanly — just use raqam's
hooks/components and apply shadcn's class names.
## RaqamInput component [#raqaminput-component]
Create a reusable `RaqamInput` component that follows the shadcn design language:
```tsx
// components/raqam-input.tsx
"use client";
import * as React from "react";
import { NumberField } from "raqam";
import { cn } from "@/lib/utils";
import type { UseNumberFieldProps } from "raqam";
// UseNumberFieldProps includes every state option plus behavior props
// (label, name, aria-*, copyBehavior, …), so name/aria pass-through type-checks.
interface RaqamInputProps extends UseNumberFieldProps {
description?: string;
className?: string;
}
export function RaqamInput({
label,
description,
className,
...props
}: RaqamInputProps) {
return (
{label && (
{label}
)}
−
+
{description && (
{description}
)}
);
}
```
## Usage [#usage]
```tsx
import { RaqamInput } from "@/components/raqam-input";
// Basic
// Currency
v !== null && v > 0 ? true : "Price must be greater than $0"}
/>
```
## Display-only with shadcn Badge [#display-only-with-shadcn-badge]
```tsx
import { Badge } from "@/components/ui/badge";
import { useNumberFieldFormat } from "raqam";
function PriceBadge({ amount }: { amount: number }) {
const formatted = useNumberFieldFormat(amount, {
locale: "en-US",
formatOptions: { style: "currency", currency: "USD" },
});
return (
{formatted}
);
}
```
## In a shadcn Form (with react-hook-form) [#in-a-shadcn-form-with-react-hook-form]
```tsx
import { useForm } from "react-hook-form";
import { Form, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { NumberField } from "raqam";
import { Controller } from "react-hook-form";
export function ProductForm() {
const form = useForm({ defaultValues: { price: null as number | null } });
return (
);
}
```
The `cn()` utility from shadcn merges Tailwind classes with `clsx` +
`tailwind-merge`. Import it from `@/lib/utils`.
---
# Tailwind CSS
Styling raqam with Tailwind CSS — data attributes, focus rings, and dark mode.
raqam ships unstyled. Tailwind classes work naturally on all components.
Use `data-*` state attributes from the root element for conditional styling.
## Basic field [#basic-field]
```tsx
import { NumberField } from "raqam";
export function PriceInput() {
return (
Price
−
+
);
}
```
## data-\* attribute styling [#data--attribute-styling]
raqam sets data attributes on the root element. Use Tailwind's `data-*` variant
for state-based styling:
```tsx
Amount
−
+
```
Available data attributes on the root:
| Attribute | When set |
| ---------------- | --------------------------- |
| `data-focused` | Input is focused |
| `data-invalid` | Value is invalid |
| `data-disabled` | `disabled={true}` |
| `data-readonly` | `readOnly={true}` |
| `data-required` | `required={true}` |
| `data-scrubbing` | A scrub drag is in progress |
The **input** element also carries `data-invalid`, `data-disabled`,
`data-readonly`, `data-required`, and `data-rtl`, so you can style it directly
(e.g. `data-[invalid]:border-red-500` on ``).
## Dark mode [#dark-mode]
```tsx
−
+
```
## Compact input (no steppers) [#compact-input-no-steppers]
```tsx
Quantity
```
## With validation styles [#with-validation-styles]
```tsx
v !== null && v > 0 ? true : "Must be positive"}
className="flex flex-col gap-1.5"
>
Price
```