A number or money field, with a sign toggle for negatives and integer minor units for money.
Basic
A plain number takes a label. hasStepper adds minus and plus buttons at the end.
- Arrows and Page Up or Page Down step by
step(1 by default). They snap to the step first, so an empty field starts at the lower bound. - Enter commits and formats the value.
- Home and End move the caret, as in any text box, and still reach your
onKeyDown. formatOptionstakesIntloptions for a decimal, a percent or a unit. Compact notation is not supported, since onlyIntlknows its rounding.
Each stepper's hit area is 40px tall, even when compact density draws it at 24px, and the two split the 2px between them.
Example
Example
import { NumberField } from '@snapshot-ui/react';
import type { ReactElement } from 'react';
export default function NumberFieldBasic(): ReactElement {
return (
<div className="stack" style={{ width: '100%', maxWidth: '20rem' }}>
<NumberField
label="Guests"
description="Up to twelve."
hasStepper
defaultValue={2}
minValue={1}
maxValue={12}
/>
</div>
);
}Money
Pass money={{ symbol: '$' }} and the value becomes an integer count of minor units: 123450 is 1,234.50. A URL and a database hold it exactly, and sums never drift. The readout under the example shows the value the field holds.
fractionDigits: 2 by default, from 0 for a currency with no minor unit up to 3.- Precision: exact up to
2 ** 52 / 10 ** fractionDigitsminor units, about 45 trillion cents. Past that, the field warns in the console. - Symbol: drawn beside the figure in slate and never typed, so the caret never lands in it. A pasted "$1,234.50" reads as the amount, and your
onPastestill runs. - No currency style:
Intl's would put the caret inside the symbol and choose its side during render. - No steppers and no wheel: an amount is typed, and a typed 12.50 is never snapped to a step.
Example
Example
Value: 123450
'use client';
import { NumberField, Text } from '@snapshot-ui/react';
import { useState, type ReactElement } from 'react';
export default function NumberFieldMoney(): ReactElement {
const [amount, setAmount] = useState<number | null>(123450);
return (
<div className="stack" style={{ width: '100%', maxWidth: '20rem' }}>
<NumberField
label="Amount"
description="Before tax"
money={{ symbol: '$' }}
value={amount}
onChange={setAmount}
/>
{/* The value is what the app stores and puts in its URL: an integer count of cents. */}
<Text tone="muted">Value: {amount === null ? 'null' : String(amount)}</Text>
</div>
);
}Negatives
Without allowsNegative, the lower bound is 0. With it, a sign button sits at the field's start, because neither iPhone nor Android decimal keyboards have a minus key.
- The button is a toggle, pressed while the value is under zero. It negates the value, kept within
minValueandmaxValue. - On an empty field or at zero, it types a minus and focuses the field, so the field never shows "-0.00" and
onChangenever receives -0. - A minus typed on a hardware keyboard works too, and the button follows it.
- A negative amount is ink, with its sign. Never red, since nothing failed.
- On a disabled or read-only field the button is disabled. A negative value then shows it pressed but recessed: a light ink fill with a taupe minus.
Example
Example
import { NumberField } from '@snapshot-ui/react';
import type { ReactElement } from 'react';
export default function NumberFieldNegative(): ReactElement {
return (
<div className="stack" style={{ width: '100%', maxWidth: '20rem' }}>
<NumberField
label="Adjustment"
description="A refund is under zero."
money={{ symbol: '$' }}
allowsNegative
defaultValue={-4250}
/>
</div>
);
}Right to left
Set the symbol's side with symbolPosition, in the field's direction. On a Persian page, both "تومان" and "$" go at the end, which Persian writing puts left of the digits. The figure stays left to right on every page, so its sign stays in front. React Aria writes it in the locale's digits after the page loads.
Example
Example
import { I18nProvider, NumberField } from '@snapshot-ui/react';
import type { ReactElement } from 'react';
export default function NumberFieldPersian(): ReactElement {
return (
<I18nProvider locale="fa-IR">
<div className="stack" dir="rtl" lang="fa" style={{ width: '100%', maxWidth: '20rem' }}>
<NumberField
label="مبلغ"
money={{ symbol: 'تومان', symbolPosition: 'end', fractionDigits: 0 }}
defaultValue={1250000}
/>
<NumberField
label="بازپرداخت"
money={{ symbol: '$', symbolPosition: 'end' }}
allowsNegative
defaultValue={-4250}
labels={{ negative: 'منفی' }}
/>
</div>
</I18nProvider>
);
}States
- Changed: the Changed chip and the light signal orange edge.
- Invalid: the error edge and error line.
- Disabled: recessed, with its symbol in taupe.
Example
Example
import { NumberField } from '@snapshot-ui/react';
import type { ReactElement } from 'react';
export default function NumberFieldStates(): ReactElement {
return (
<div className="stack" style={{ width: '100%', maxWidth: '20rem' }}>
<NumberField label="Budget" money={{ symbol: '$' }} defaultValue={250000} isChanged />
<NumberField
label="Limit"
money={{ symbol: '$' }}
defaultValue={1500000}
isInvalid
errorMessage="Enter an amount under $10,000."
/>
<NumberField label="Deposit" money={{ symbol: '$' }} defaultValue={5000} isDisabled />
</div>
);
}Focus leaving while React draws
React Aria commits the typed text when the field loses focus, inside flushSync. When focus leaves while React draws, as when a Save disables the form's fields or an app moves focus from a layout effect, React used to log "flushSync was called from inside a lifecycle method". The field now defers React Aria's commit by one microtask, so nothing is logged. The value still commits before the next click and before a submit, and a corrected value is still announced.
- Your
onBlurandonFocusChange(false)run one microtask after the blur. - A script that calls
requestSubmit()in the same task as a blur reads the form before the commit. Submit from the press instead, or wait a microtask.
Usage
Keep an amount filter in the URL as minor units, ?min=2000. A client component reads it with Number(), treating an absent or malformed value as null, and passes it as value. It writes the URL in onChange, which fires when the value commits (on blur, Enter, a step or the sign button), never on a keystroke. With the Next App Router:
'use client';
import { useState } from 'react';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { NumberField } from '@snapshot-ui/react';
const minorOf = (raw: string | null): number | null =>
raw === null || raw === '' || !Number.isSafeInteger(Number(raw)) ? null : Number(raw);
export function MinimumAmount() {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const min = minorOf(searchParams.get('min'));
const [value, setValue] = useState(min);
const [seen, setSeen] = useState(min);
// The URL changed under the field, by Back, Forward or a link: show its amount.
if (min !== seen) {
setSeen(min);
setValue(min);
}
return (
<NumberField
label="Minimum amount"
money={{ symbol: '$' }}
value={value}
onChange={(next) => {
setValue(next);
setSeen(next);
const params = new URLSearchParams(searchParams);
if (next === null) params.delete('min');
else params.set('min', String(next));
const query = params.toString();
router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false });
}}
/>
);
}- On a statically rendered route, render it inside
<Suspense>with an empty field as the fallback. - In a form,
namesends minor units in money mode and the number otherwise. - Before hydration, the field shows the plain figure, "1234.50", in ASCII digits with no grouping. React Aria's formatted text, "1,234.50" or "۱٬۲۳۴٫۵۰", follows in the next render. The server's HTML is the same in every locale and time zone, and nothing moves.
Example
Example
'use client';
import { NumberField } from '@snapshot-ui/react';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { Suspense, useState, type ReactElement } from 'react';
/** The parameter as minor units, or null when it is absent or not a whole number. */
const minorOf = (raw: string | null): number | null => {
if (raw === null || raw === '') return null;
const value = Number(raw);
return Number.isSafeInteger(value) ? value : null;
};
function MinimumField({
min,
onChange,
}: {
min: number | null;
onChange?: (next: number | null) => void;
}): ReactElement {
return (
<NumberField
label="Minimum amount"
money={{ symbol: '$' }}
placeholder="0.00"
value={min}
onChange={onChange}
/>
);
}
function MinimumFromUrl(): ReactElement {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const min = minorOf(searchParams.get('min'));
const [value, setValue] = useState(min);
// The amount the field last took from the URL, or last wrote to it.
const [seen, setSeen] = useState(min);
// The URL changed under the field, by Back, Forward or a link: show its amount.
if (min !== seen) {
setSeen(min);
setValue(min);
}
return (
<MinimumField
min={value}
onChange={(next) => {
setValue(next);
setSeen(next);
// Start from the parameters already there, so the other filters stay.
const params = new URLSearchParams(searchParams);
if (next === null) params.delete('min');
else params.set('min', String(next));
const query = params.toString();
router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false });
}}
/>
);
}
export default function NumberFieldUrl(): ReactElement {
return (
<div className="stack" style={{ width: '100%', maxWidth: '20rem' }}>
{/* The page is static, so the server renders the fallback: the same field, empty. */}
<Suspense fallback={<MinimumField min={null} />}>
<MinimumFromUrl />
</Suspense>
</div>
);
}Props
Every React Aria NumberField prop is accepted except the steppers' names, which come from labels. These are the ones Snapshot UI adds or changes. validate receives the value as onChange does.
allowsNegative boolean | Values under zero, and the sign button. Default: false |
|---|---|
aria-label string | Defines a string value that labels the current element. |
changedLabel string | Default: 'Changed' |
className string | ((values: NumberFieldRenderProps & { defaultClassName: string | undefined; }) => string) | A class name, or a function of the field's state, merged after the library's own classes. |
defaultValue number | null | The number the field starts with when |
description ReactNode | A hint under the control. It becomes the control's accessible description. |
errorMessage ((validation: ValidationResult) => ReactNode) | ReactNode | The error line, shown only while the field is invalid. A function receives the validation result. Without it the field shows its own validation messages. |
formatOptions (Omit<Intl.NumberFormatOptions, "compactDisplay" | "currency" | "notation" | "style"> & { style?: 'decimal' | 'percent' | 'unit'; }) |
|
hasStepper boolean | Minus and plus buttons at the inline end. Default: false |
isChanged boolean | Whether the value differs from the saved one. Shows the clay Changed chip and the light signal orange border. A screen reader reads the chip with the field. |
isDisabled boolean | Whether the input is disabled. |
isInvalid boolean | Whether the input value is invalid. |
isReadOnly boolean | Whether the input can be selected but not changed by the user. |
isRequired boolean | Whether user input is required on the input before form submission. |
label ReactNode | The field's name, shown above the control. Required unless the field has an |
labels Partial<NumberFieldLabels> | The names the field writes, in English unless replaced. |
maxValue number | The largest value allowed for the input. |
minValue number | The smallest value allowed for the input. |
money MoneyFormat | Money: the value is an integer count of minor units, the symbol is drawn beside the figure, and there are no steppers, no wheel and no step. |
name string | The name of the input element, used when submitting an HTML form. See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#htmlattrdefname). |
onChange ((value: number | null) => void) | Called when the value commits (blur, Enter, a step or the sign button), with null for an empty field. In money mode an integer count of minor units. |
placeholder string | Example text while empty. Taupe under a visible label, slate without one, taupe while disabled. |
step number | The arrows' step, which a committed value also snaps to. Default: 1 |
validate ((value: number | null) => ReturnType<NonNullable<RACNumberFieldProps['validate']>>) | Checks the value as |
value number | null | The number, or null while empty. In money mode an integer count of minor units. |
fractionDigits 0 | 1 | 2 | 3 | Digits after the decimal separator; the value counts in these minor units. Default: 2 |
|---|---|
symbol string | The currency's symbol or word, drawn beside the figure and never typed: '$', 'تومان'. |
symbolPosition "end" | "start" | The side of the figure it sits on, in the field's direction. Default: 'start' |
decrement string | Default: 'Decrease' |
|---|---|
increment string | The plus stepper's name; the field's label follows it. Default: 'Increase' |
negative string | The sign button's name. A toggle, pressed while the value is under zero. Default: 'Negative' |
Accessibility
The field is a textbox (React Aria removes the spin button role), named by its label and described by its hint, symbol and Changed chip: "Amount, 1,234.50, $". React Aria announces a committed value in its own strings.
- Sign button: a toggle with
aria-pressed, named "Negative" orlabels.negative. It is its own tab stop. - Steppers: named "Increase" and "Decrease" plus the field's label. They are out of the tab order, since the arrows do the same.
| 0-9.- | Type in locale or Latin digits. A minus only where negatives are allowed. |
|---|---|
| UpDown | Step by the step, or one whole unit for money, after snapping to it. |
| PageUpPageDown | Step as the arrows do. |
| HomeEnd | Move the caret, instead of setting the minimum or maximum. |
| Enter | Commits and formats the value. |
| Tab | Leaves the field. The sign button is its own stop, the steppers are not. |
Design rules
What keeps number fields in the system.
Keep money in the URL and the database as integer minor units.
Write the symbol the app uses, on the side its language puts it.
Allow negatives, and so the sign toggle, only where an amount can really be under zero.
Never use a currency style in the field. The symbol is drawn beside it.
Never draw a negative amount in red. The sign carries it.
Never put steppers on money.