# Raqam — full documentation > The definitive React number input: live formatting, full i18n, headless, accessible. --- # Overview The definitive React number input — live formatting, full i18n, headless, accessible. **raqam** is a headless React number input with live, cursor-stable formatting, full internationalization, and first-class accessibility — in a \~2.2 KB core. Every other React number input forces a trade-off. raqam eliminates all four. Formats as you type using the same cursor-boundary algorithm as react-number-format — no flicker, cursor stays in place. Accepts Persian ۱۲۳, Arabic ١٢٣, Bengali ১২৩, Devanagari १२३, Thai ๑๒๓. Separators extracted dynamically via `Intl.NumberFormat`. Zero styles. Bring Tailwind, CSS Modules, or any design system. Compound components or raw hooks — your choice. WAI-ARIA `spinbutton` role, `aria-valuenow/min/max/valuetext`, keyboard navigation (↑ ↓ PgUp PgDn Home End), focus management. ## Install [#install] 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. ## Thirty-second example [#thirty-second-example] ```tsx import { NumberField } from "raqam"; export function PriceInput() { return ( Price + ); } ``` Type `1234` → see `$1,234.00`. Type `1234.5` → see `$1,234.5` (live, no flicker). ## Where to next [#where-to-next] * [Getting Started](/docs/getting-started) — install and build your first field. * [Playground](/docs/playground) — try raqam live in the browser. * [API Reference](/docs/api/use-number-field-state) — hooks, components, presets, utilities. * [Guides](/docs/guides/formatting) — formatting, locales, RTL, Next.js, accessibility. * [Recipes](/docs/recipes/react-hook-form) — react-hook-form, Formik, Tailwind, shadcn/ui, and more. ## Bundle size [#bundle-size] Measured min + brotli (including dependencies), enforced in CI: | Import | Size | CI budget | | ------------------ | --------- | --------- | | `raqam/core` | \~2.23 KB | 2.5 KB | | `raqam` (full) | \~9.62 KB | 12 KB | | `raqam/react` | \~9.48 KB | 10 KB | | `raqam/locales/fa` | 196 B | 0.3 KB | --- # Advanced Primitives Lower-level hooks and context exports for design-system authors and custom number field primitives. These exports are intentionally low-level. Most apps should use [`useNumberFieldState`](/docs/api/use-number-field-state), [`useNumberField`](/docs/api/use-number-field), or the [`NumberField` components](/docs/api/components). ## Import [#import] ```ts import { useControllableState, usePressAndHold, useScrubArea, NumberFieldContext, useNumberFieldContext, } from "raqam"; ``` ## `useControllableState(options)` [#usecontrollablestateoptions] Tiny controlled/uncontrolled state helper used internally by raqam. ```ts const [value, setValue] = useControllableState({ value: controlledValue, defaultValue: 0, onChange: setControlledValue, }); ``` Use it when you are building your own wrapper component and want the same controlled/uncontrolled semantics as `NumberField.Root`. ## `usePressAndHold(callback, options)` [#usepressandholdcallback-options] Returns pointer handlers that fire once immediately, then repeat with acceleration while the pointer stays down. ```tsx const hold = usePressAndHold(() => stepBy(1), { delay: 400, interval: 200, disabled, }); ``` Options: | Option | Default | Description | | ---------- | ------- | ------------------------------------------------------------- | | `delay` | `400` | Milliseconds before repeating starts. | | `interval` | `200` | Initial repeat interval; it accelerates down to a 50ms floor. | | `disabled` | `false` | Disables the handlers. | The returned handlers include `onPointerCancel` and `onLostPointerCapture` (in addition to `onPointerDown`/`onPointerUp`/`onPointerLeave`); spreading the whole object wires them all. The repeat loop also stops on its own the moment `disabled` flips to `true` mid-hold — important because a disabled ` ); } ``` --- # 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 ( <>

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
Price
``` 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 (

Original price: {formattedPrice}

Custom price
); } ``` ## 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 (
formik.setFieldValue("quantity", v)} onBlur={() => formik.setFieldTouched("quantity")} minValue={1} step={1} validate={(v) => v !== null && v < 1 ? "Must be at least 1" : true } > Quantity + {formik.touched.quantity && formik.errors.quantity && ( {formik.errors.quantity} )} formik.setFieldValue("price", v)} onBlur={() => formik.setFieldTouched("price")} > Unit price {formik.touched.price && formik.errors.price && ( {formik.errors.price} )}
); } ``` ## 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 (
formik.setFieldValue("price", v ?? 0)} onBlur={() => formik.setFieldTouched("price")} > Price {formik.touched.price && formik.errors.price && (

{formik.errors.price}

)}
); } ``` ## 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 (
console.log(data))}> ( Price + {errors.price && (

{errors.price.message}

)}
)} /> ); } ``` ## 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 (
( Price {fieldState.error && (

{fieldState.error.message}

)}
)} /> ); } ``` ## 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 (
( Price + )} /> ); } ``` 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 ```