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.
  • formatOptions takes Intl options for a decimal, a percent or a unit. Compact notation is not supported, since only Intl knows 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

Changed
Up to twelve.
Follows the page theme and corners
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 ** fractionDigits minor 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 onPaste still 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

Changed
$
Before tax

Value: 123450

Follows the page theme and corners
'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 minValue and maxValue.
  • On an empty field or at zero, it types a minus and focuses the field, so the field never shows "-0.00" and onChange never 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

Changed
$
A refund is under zero.
Follows the page theme and corners
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

Changed
تومان
Changed
$
Follows the page theme and corners
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

Changed
$
$
Enter an amount under $10,000.
Changed
$
Follows the page theme and corners
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 onBlur and onFocusChange(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, name sends 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

Changed
$
Follows the page theme and corners
'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 value is not given. In money mode an integer count of minor units.

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'; })

Intl options for a plain number, applied after hydration. No currency style and no compact notation, whose rounding only Intl knows.

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 aria-label or an aria-labelledby.

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 onChange sends it: minor units in money mode, null when empty. Returns an error message, or true or null when valid.

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" or labels.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.
UpDownStep by the step, or one whole unit for money, after snapping to it.
PageUpPageDownStep as the arrows do.
HomeEndMove the caret, instead of setting the minimum or maximum.
EnterCommits and formats the value.
TabLeaves 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.

esc

↑ ↓ to move↵ to open