The box a person types a number into, or steps up and down with its minus and plus buttons and the arrow keys, such as how many departures a board shows, framed by the Field that gives it its label, its line of help and its error message.
When to use
Use NumberField for a count or an amount a person types or steps up and down, such as how many departures a board shows.
Kongens Nytorv
Metro M1, M2 and M3.
From 1 to 20
Variants and states
| Rest | Disabled | |
|---|---|---|
| Default | From 1 to 20 | From 1 to 20 |
- Rest
From 1 to 20
- Disabled
From 1 to 20
NumberField styles hover, pressed and focus on its inner parts, which a still preview can't reach, so this table shows rest and disabled only.
Props
| Name | Type | Default | Description |
|---|---|---|---|
label* | ReactNode | — | Required. The field's name, in sentence case, such as "Departures to show": shown above the box, and the input's accessible name. |
hiddenLabel | boolean | false | Whether the label is heard but not seen. Use it only where the screen around the field already says what it is for. |
optional | boolean | false | Whether the person may leave the field empty. Adds "(optional)" after the label's words, seen and heard as part of the input's name. It sets no check: required does. |
optionalLabel | string | The locale's | Overrides "(optional)", the mark after the label's words with optional. |
description | ReactNode | — | A line of help between the label and the box, such as "From 1 to 20", read after the label by a screen reader. Left out, no description is rendered. |
error | ReactNode | — | What is wrong with the number and how to put it right, such as "Enter a number from 1 to 20". Given, it marks the field invalid and is shown under the box; left out, the field shows Base UI's own message when the number fails its checks. |
disabled | boolean | false | Whether the field ignores the person: the box takes no focus, no typing and no presses, and the label, the help, the number and the buttons turn to the tertiary ink. |
onChange | (event: Event, value: number | null, details: NumberField.Root.ChangeEventDetails) => void | — | Called when the number changes: as the person types a number that parses (details.reason 'input-change'), clears the box ('input-clear', value null), leaves it and the number is clamped or rounded ('input-blur'), pastes ('input-paste'), steps it with the keys ('keyboard'), presses a button ('increment-press', 'decrement-press') or turns the wheel ('wheel'), or the browser autofills Base UI's hidden input ('none'). event is details.event, and details.cancel() keeps the number as it was. |
onChangeCommitted | (event: Event, value: number | null, details: NumberField.Root.CommitEventDetails) => void | — | Called once the person has settled on a number: when they leave the box after typing, release a held button, or with each key step and wheel turn; event is details.event. Use it to save or fetch, where onChange fires on every step of a held button. |
locale | LocalesArgument | The PerronProvider's locale | The locale the number is written and read in: "1,500" in English, "1.500" in Danish, "1 500" in Swedish and Norwegian. Left out, the nearest PerronProvider's. |
placeholder | string | — | Words shown in the empty box, such as "Such as 8". They vanish as the person types, so they never replace the label or the description. |
increaseLabel | string | The locale's | Overrides "Increase", the increment button's name. |
decreaseLabel | string | The locale's | Overrides "Decrease", the decrement button's name. |
numberFieldLabel | string | The locale's | Overrides "Number field", what a screen reader calls the input in place of its role (Owned strings). |
ref | Ref<HTMLDivElement> | — | Reaches the root, the NumberField.Root <div>. |
slotProps | { field?: Omit<FieldRootProps, "children" | "disabled" | "name"> | undefined; label?: FieldLabelProps | undefined; description?: FieldDescriptionProps | undefined; error?: FieldErrorProps | undefined; group?: NumberFieldGroupProps | undefined; input?: NumberFieldInputProps | undefined; decrement?: NumberFieldDecrementProps | undefined; increment?: NumberFieldIncrementProps | undefined; } | — | Props for each part: field, label, description, error, group, input, decrement, increment. |
label (required)ReactNode, default —- Required. The field's name, in sentence case, such as "Departures to show": shown above the box, and the input's accessible name.
hiddenLabelboolean, defaultfalse- Whether the label is heard but not seen. Use it only where the screen around the field already says what it is for.
optionalboolean, defaultfalse- Whether the person may leave the field empty. Adds "(optional)" after the label's words, seen and heard as part of the input's name. It sets no check:
requireddoes. optionalLabelstring, default The locale's- Overrides "(optional)", the mark after the label's words with
optional. descriptionReactNode, default —- A line of help between the label and the box, such as "From 1 to 20", read after the label by a screen reader. Left out, no description is rendered.
errorReactNode, default —- What is wrong with the number and how to put it right, such as "Enter a number from 1 to 20". Given, it marks the field invalid and is shown under the box; left out, the field shows Base UI's own message when the number fails its checks.
disabledboolean, defaultfalse- Whether the field ignores the person: the box takes no focus, no typing and no presses, and the label, the help, the number and the buttons turn to the tertiary ink.
onChange(event: Event, value: number | null, details: NumberField.Root.ChangeEventDetails) => void, default —- Called when the number changes: as the person types a number that parses (
details.reason'input-change'), clears the box ('input-clear',valuenull), leaves it and the number is clamped or rounded ('input-blur'), pastes ('input-paste'), steps it with the keys ('keyboard'), presses a button ('increment-press','decrement-press') or turns the wheel ('wheel'), or the browser autofills Base UI's hidden input ('none').eventisdetails.event, anddetails.cancel()keeps the number as it was. onChangeCommitted(event: Event, value: number | null, details: NumberField.Root.CommitEventDetails) => void, default —- Called once the person has settled on a number: when they leave the box after typing, release a held button, or with each key step and wheel turn;
eventisdetails.event. Use it to save or fetch, whereonChangefires on every step of a held button. localeLocalesArgument, default The PerronProvider's locale- The locale the number is written and read in: "1,500" in English, "1.500" in Danish, "1 500" in Swedish and Norwegian. Left out, the nearest PerronProvider's.
placeholderstring, default —- Words shown in the empty box, such as "Such as 8". They vanish as the person types, so they never replace the label or the description.
increaseLabelstring, default The locale's- Overrides "Increase", the increment button's name.
decreaseLabelstring, default The locale's- Overrides "Decrease", the decrement button's name.
numberFieldLabelstring, default The locale's- Overrides "Number field", what a screen reader calls the input in place of its role (Owned strings).
refRef<HTMLDivElement>, default —- Reaches the root, the
NumberField.Root<div>. slotProps{ field?: Omit<FieldRootProps, "children" | "disabled" | "name"> | undefined; label?: FieldLabelProps | undefined; description?: FieldDescriptionProps | undefined; error?: FieldErrorProps | undefined; group?: NumberFieldGroupProps | undefined; input?: NumberFieldInputProps | undefined; decrement?: NumberFieldDecrementProps | undefined; increment?: NumberFieldIncrementProps | undefined; }, default —- Props for each part:
field,label,description,error,group,input,decrement,increment.
Use it
import { NumberField } from '@mylder/perron'Ask your agent
get_reference({ library: "base-ui", component: "NumberField" })Keyboard and ARIA
From the Base UI reference for NumberField and the installed NumberFieldInput, NumberFieldRoot and useNumberFieldStepperButton. "From" says who does it. NumberField adds no keys.
| Key | Does | From |
|---|---|---|
| Typing a digit or a separator | Changes the text and, once it parses as a number in the locale, the value, calling onChange ('input-change'); a character that can't be part of a number is not typed | Base UI |
ArrowUp, ArrowDown | Step the value up or down by step, clamped to min and max, calling onChange and onChangeCommitted ('keyboard') | Base UI |
Shift + ArrowUp, ArrowDown | Step by largeStep, 10 by default | Base UI |
Alt + ArrowUp, ArrowDown | Step by smallStep, 0.1 by default | Base UI |
Home, End | Set the value to min or max, when it is set; without it, move the caret as the browser does | Base UI |
Tab, Shift+Tab | Move focus to and from the input, the field's one tab stop; the buttons take none. Leaving marks the field touched; after typing, or when format rounds the number, even one set from outside by value, it also writes the number in the locale, clamps it to min and max unless allowOutOfRange is set, and calls onChangeCommitted. A change to value from outside while the input had focus is not committed on leaving, unless format rounds it. Leaving a read-only field only marks it touched | Base UI |
Enter | In a form with a submit button, the browser clicks it and submits the form; the number is the hidden input's value. A form without one isn't submitted, since the hidden input is a second field | Browser |
| A press on a button | Steps once, then every 60 ms while held past 400 ms, calling onChange on each step and onChangeCommitted on release; a mouse press focuses the input | Base UI |
| A press on the label | Focuses the input: natively through htmlFor, or through Base UI's own handler for a hidden label | Base UI |
| The rest | Move the caret, select, cut, copy and paste, as the browser's own <input> does; a paste is spliced in at the caret when the result parses | Browser |
| Element | Role | Accessible name from | States announced |
|---|---|---|---|
root | none: a <div> | none | none |
group | group, from Base UI | none: the input inside it carries the name | none |
input | textbox, native, described as the owned "Number field" by aria-roledescription | aria-labelledby the label, and its native label through htmlFor: "Departures to show", or "Departures to show (optional)" with optional | Its value as text, formatted in the locale; aria-describedby the description, then the mounted error; aria-invalid while invalid and not disabled; required; disabled; readonly |
decrement | button, native | aria-label, the owned "Decrease", or decreaseLabel | aria-disabled at min, while read-only and while disabled; aria-controls the input |
increment | button, native | aria-label, the owned "Increase", or increaseLabel | aria-disabled at max, while read-only and while disabled; aria-controls the input |
icon | none: aria-hidden="true" | none | none |
| hidden input | none: aria-hidden, tabIndex −1, Base UI's | none | none: out of the tree |
field | none: a <div> | none | none |
label | none: a native <label>, or a hidden span | Its text: the caller's label, then "(optional)" with optional | none |
description | none: a <p> | none | Read as the input's description |
error | none: a <div> | none | Read as the input's description while it is mounted, with no live region of its own (Field's spec) |
The buttons stay out of the tab order and in the accessibility tree, as Base UI keeps them: a keyboard steps the number with the arrow keys on the input, which says so through its role description, and a touch screen reader, which has no arrow keys, reaches the buttons by swiping.
What axe won't catch, from the contract, each with how this component meets it:
- Everything the eye is told, the ear is told, for every shape each prop accepts: the label names the input, even hidden, with the
optionalmark inside it; the description and the error are the input'saria-describedby; an invalid field isaria-invalidand its error is words beside the red edge; each button's icon isaria-hiddenand its name says what it does; a button the eye sees in the tertiary ink isaria-disabledfor the ear, at a bound, read-only or disabled; the placeholder is never the name. - Every landmark and named region has an accessible name: none here; Base UI's
groupis no landmark, and a group of fields is a Fieldset's to name. - Every default label can be overridden:
increaseLabel,decreaseLabelandnumberFieldLabeloverride NumberField's three owned strings, andoptionalLabelField's. - Every shape the type accepts renders, in every variant:
label,descriptionanderrorrender a string, an element and a list alike, as Field's do;valuerenders a number and an empty box fornull; aformatwith a unit or a percent sign renders inside the box; a long Danish compound wraps in the label, the description and the error, and a long number stays on one scrolling line in the box (CR-10). - A hidden copy kept for animation is
aria-hidden, and a collapsed region isinert: none here; the error is unmounted while the field is valid, and Base UI's hidden input isaria-hiddenbecause the visible input is the control. - Native semantics first: a native
<input>, a native<label>and two native<button>s; Perron addsaria-labelon the buttons andaria-roledescriptionon the input, each in place of Base UI's English, and Base UI addsrole="group",aria-labelledby,aria-describedby,aria-invalid,aria-controlsandaria-disabled. - Focus moves in reading order and returns on close: the input is the field's one tab stop, between the label and description above it and the error below; a mouse press on a button leaves focus on the input; nothing opens or closes.
- Every target is at least
size.target-minsquare: each button is asize.controlsquare, 34 px; the box issize.inputtall and as wide as its container. - Loops stop when the system asks for less motion: none here; a held button repeats a step, which is no animation, and the box has no transition (CR-11).
Owned strings
The buttons' names are owned strings with the keys increase and decrease, in English "Increase" and "Decrease", set as each button's aria-label in place of Base UI's hard-coded English. Base UI names the buttons in English in every locale, so a Danish screen reader would hear "Increase", which the contract's Words forbids; "Increase" and "Decrease" are Base UI's own words, sentence case and plain, naming what the press does to the number whatever it stands for; and aria-label is how Base UI already names them, which expectOwnedStrings reads, where a VisuallyHidden span would first have to undo it.
The input's role description is an owned string, key numberField, in English "Number field", set as aria-roledescription in place of Base UI's hard-coded English rather than removed. Base UI sets aria-roledescription="Number field" in every locale, so a Danish screen reader would call the box a "Number field"; the description is what tells a screen reader user the arrow keys step it, since the buttons take no focus; and packages/perron/src/testing/owned-strings.ts reads no aria-roledescription today, so the build adds it to the attributes the check reads, with a test, or this word would pass the check in English on every screen (CR-9).
| Key | English | Override | Seen or heard where |
|---|---|---|---|
increase | "Increase" | increaseLabel | The increment button's aria-label, heard as its name; never seen |
decrease | "Decrease" | decreaseLabel | The decrement button's aria-label, heard as its name; never seen |
numberField | "Number field" | numberFieldLabel | The input's aria-roledescription, heard in place of its role, after its name; never seen |
optional | "(optional)" | optionalLabel | Field's optional span, seen after the label's words with optional, and heard in the input's name |
increase, decrease and numberField are new keys in packages/perron/src/locale/en.ts, each with a JSDoc line saying where it is heard, and agents write their Danish, Swedish and Norwegian in the other locale files. optional is the key it already has, Field's: NumberField passes optional and optionalLabel to Field, which reads it. Every other word is the caller's, through label, description, error, placeholder and the units a format writes, or Base UI's message for a failing check: the caller's validate result, or the browser's own message for the hidden input's required, min, max and step, Field's known exception, which applies here unchanged (Field). The number's digits, separators and signs hold no letter, so expectOwnedStrings passes them in every locale; a unit a format writes, such as "min", is the caller's word, given in the story's caller strings.
Do and don't
For a caller using the component, with the reason. Each row is a rule: a must row's Don't is never right, a should row's needs a reason, and Checked by says what catches the Don't in a caller's code.
| Rule | Level | Do | Don't | Checked by |
|---|---|---|---|---|
| DD-1 | must | Use NumberField for a count or an amount a person may nudge up or down, such as the departures a board shows | Use it for a number that is a name, such as a line number, a stop code or a phone number: no one steps those, and a separator or a dropped leading zero would change them; use TextField | review |
| DD-2 | should | Set min and max wherever the number has bounds, and say them in description, "From 1 to 20" | Leave the bounds to an error after the fact: the buttons and the keys clamp to them only when they are set | review |
| DD-3 | should | Pass the unit through format, such as { style: 'unit', unit: 'minute' }, so it is written in the locale with a space before it | Write the unit into label alone, or into a text beside the box: the number in the box would read bare | review |
| DD-4 | must | Read the number from onChange's second argument, and save or fetch on onChangeCommitted | Read event.target.value: it is the formatted text, "1,500", not the number; and don't fetch on every onChange, which fires on each step of a held button | review |
| DD-5 | must | Connect React Hook Form through Controller, with onChange={(event, value) => field.onChange(value)} | Spread register() on NumberField: its value is a number Base UI keeps, not a native input's text | review |
| DD-6 | must | Let the PerronProvider's locale write the number, or give locale only where the number must be written in another language | Format the number yourself and pass a string: value is a number, and Base UI parses what the person types in the same locale | types |
| DD-7 | must | Give error from the form library whenever the field is invalid, worded as what to do, "Enter a number from 1 to 20" | Set slotProps.field.invalid with no message, or blame the person ("Invalid number"), against Content design | review |
| DD-8 | should | Set the width on the container, or on the frame through slotProps.field, wide enough for the longest number and both buttons | Squeeze the box until the number scrolls: the buttons keep their size, and the number is what the person reads | review |
| DD-9 | must | Use NumberField on its own: it already has its label, its help and its error | Wrap NumberField in Field: the input would be labelled twice, as Field's spec says | review |