Skip to content
UniAria
Color scheme
Contrast
Color scheme
Contrast

NumberField

A number field is a text input for a number, with optional increment and decrement buttons. It parses and formats the number for the locale and your format (currencies, percentages, units, other numbering systems), keeps it within minValue and maxValue and on step, and steps it with the arrow keys, the buttons held down and, while focused, the mouse wheel. It has Field built in for the label, description and error.

The input is a native <input type="text">, not type="number", which can’t show a currency symbol, a percent sign or grouping. Parsing and formatting come from @internationalized/number.

NumberField follows the keys and buttons of the WAI-ARIA Spinbutton pattern; its input is a text box rather than a spinbutton (see Accessibility).

Number fields
View source
<!doctype html><meta charset="utf-8"><style>/* The page's layout. The number fields' look is @uniaria/styles' default. */
:root {
  font:
    15px/1.5 system-ui,
    sans-serif;
}
body {
  display: grid;
  gap: 1rem;
  margin: 1.5rem;
  background: var(--uni-surface-color);
  color: var(--uni-text-color);
}
form {
  display: grid;
  gap: 1rem;
  justify-items: start;
}
.row {
  display: flex;
  flex-wrap: wrap;
  gap: 1.5rem;
}
.row > uni-number-field {
  inline-size: 12rem;
}
</style><form id="order">
  <div class="row">
    <uni-number-field
      id="quantity"
      min-value="1"
      max-value="10"
      default-value="2"
    >
      <div data-uni="number-field">
        <label data-element="Label">Quantity</label>
        <div data-element="Wrapper">
          <button data-element="Decrement">−</button>
          <input data-element="Input" />
          <button data-element="Increment">+</button>
        </div>
        <input type="hidden" data-element="HiddenInput" name="quantity" />
        <p data-element="Description">Up to 10 per order.</p>
      </div>
    </uni-number-field>

    <uni-number-field
      id="price"
      locale="de-DE"
      min-value="0"
      default-value="12.5"
    >
      <div data-uni="number-field">
        <label data-element="Label">Price</label>
        <div data-element="Wrapper">
          <input data-element="Input" />
        </div>
        <input type="hidden" data-element="HiddenInput" name="price" />
        <p data-element="Description">In euros, formatted for German.</p>
      </div>
    </uni-number-field>

    <uni-number-field
      id="deposit"
      locale="de-DE"
      min-value="0"
      default-value="50"
    >
      <div data-uni="number-field">
        <label data-element="Label">Deposit</label>
        <div data-element="Wrapper">
          <input data-element="Input" />
          <span aria-hidden="true">€</span>
        </div>
        <input type="hidden" data-element="HiddenInput" name="deposit" />
        <p data-element="Description">In euros, the sign as a suffix.</p>
      </div>
    </uni-number-field>
  </div>

  <uni-button type="submit">
    <button data-uni="button" data-variant="primary">Add to cart</button>
  </uni-button>
</form>
<output id="log">Step with the arrow keys, the buttons or the wheel.</output>
<script src="/demo/number-field.js"></script><script>const form = document.getElementById('order');
const log = document.getElementById('log');

// Format options are a property only.
document.getElementById('price').formatOptions = {
  style: 'currency',
  currency: 'EUR',
};
// The sign is markup beside the input, so the format has none.
document.getElementById('deposit').formatOptions = {
  minimumFractionDigits: 2,
  maximumFractionDigits: 2,
};

form.addEventListener('valuechange', (event) => {
  log.textContent = `valuechange on ${event.target.id}: ${event.detail}`;
});

// The hidden inputs give the form plain numbers, whatever the text shows.
form.addEventListener('submit', (event) => {
  event.preventDefault();
  log.textContent = `Submitted ${new URLSearchParams(new FormData(form))}`;
});
</script>

Usage

React

import { numberField } from '@uniaria/core/number-field';
import { useBehavior } from '@uniaria/react';

export function QuantityField(props: {
  onQuantityChange(quantity: number | null): void;
}) {
  const quantity = useBehavior(numberField, {
    defaultValue: 1,
    minValue: 1,
    maxValue: 99,
    onValueChange: props.onQuantityChange,
  });
  return (
    <div {...quantity.parts.root}>
      <label {...quantity.parts.label}>Quantity</label>
      <div {...quantity.parts.wrapper}>
        <button {...quantity.parts.decrement}>−</button>
        <input {...quantity.parts.input} />
        <button {...quantity.parts.increment}>+</button>
      </div>
      <input {...quantity.parts.hiddenInput} name="quantity" />
    </div>
  );
}

Vue

<script setup lang="ts">
import { numberField } from '@uniaria/core/number-field';
import { useBehavior } from '@uniaria/vue';

const emit = defineEmits<{ quantityChange: [quantity: number | null] }>();

const quantity = useBehavior(numberField, {
  defaultValue: 1,
  minValue: 1,
  maxValue: 99,
  onValueChange: (value) => emit('quantityChange', value),
});
</script>

<template>
  <div v-bind="quantity.parts.root">
    <label v-bind="quantity.parts.label">Quantity</label>
    <div v-bind="quantity.parts.wrapper">
      <button v-bind="quantity.parts.decrement">−</button>
      <input v-bind="quantity.parts.input" />
      <button v-bind="quantity.parts.increment">+</button>
    </div>
    <input v-bind="quantity.parts.hiddenInput" name="quantity" />
  </div>
</template>

HTML

<uni-number-field id="quantity" min-value="1" max-value="99" default-value="1">
  <div data-uni="number-field">
    <label data-element="Label">Quantity</label>
    <div data-element="Wrapper">
      <button data-element="Decrement">−</button>
      <input data-element="Input" />
      <button data-element="Increment">+</button>
    </div>
    <input type="hidden" data-element="HiddenInput" name="quantity" />
  </div>
</uni-number-field>

<script type="module">
  import '@uniaria/elements/number-field';

  document
    .querySelector('#quantity')
    .addEventListener('valuechange', (event) => {
      console.log(event.detail); // a number, or null when cleared
    });
</script>

Without default-value, the element takes the input’s text as its default, parsed for the field’s locale: the markup’s value, or what the user typed before the script loaded. The value property reads the number; defaultValue reads the text as it was adopted.

The value is a number, or null when the field is empty. It changes when the text is committed and on each step, never on each keystroke. The buttons are optional: leave them out for a plain formatted input.

Typing and committing

  • Each edit is checked before the browser applies it. It stays only if the text it leaves can still become a number for the locale and format: digits, the format’s symbols (a currency sign, %, unit words), group separators, one decimal separator when the format shows fractions, and a minus sign at the start when minValue is unset or below 0. Typing 1a leaves 1. Undo and redo are never refused.
  • Typing reports nothing. The text is committed when the input loses focus, on Enter, and right after a paste that replaces all of it, so pasting $1,234.50 into a dollar field works.
  • A commit turns empty text into null and puts back text that isn’t a number. A number is kept within minValue and maxValue, moved to the nearest step when step is set (counted from minValue, or 0, and never past the last step below maxValue), and rounded to the precision the format shows: 3 decimals for plain numbers by default. The text is then formatted again, and valueChange reports the value if it changed.

Stepping

The arrow and page keys, Home and End, the buttons and the wheel step the value. Each step commits at once: the value, the text and valueChange together.

  • A step is step, else 1, or 1% for style: 'percent'. A page is pageStep, or 10 steps.
  • Steps go from the text as typed, committed or not. From an empty field, increment goes to minValue and decrement to maxValue, else both to 0.
  • A value off the step goes to the next step that way: from 2 with steps of 5, up is 5.
  • At a bound a step does nothing, and its button is disabled.
  • Steps add in decimal, so 0.1 plus 0.2 shows 0.3.

A press on a button steps at once and, held for 400 ms, again every 60 ms until released or at the bound. A touch steps on release instead, or repeats after 600 ms, and a scroll that takes over the touch cancels it. The wheel steps while focus is in the field and the pointer is over the input: one step per event, up increments, and the page doesn’t scroll. Turn it off with isWheelDisabled.

Formatting and locale

React

const price = useBehavior(numberField, {
  locale: 'de-DE',
  formatOptions: { style: 'currency', currency: 'EUR' },
  minValue: 0,
});
// 12.5 shows as "12,50 €"

Vue

<script setup lang="ts">
const price = useBehavior(numberField, {
  locale: 'de-DE',
  formatOptions: { style: 'currency', currency: 'EUR' },
  minValue: 0,
});
// 12.5 shows as "12,50 €"
</script>

HTML

<uni-number-field id="price" locale="de-DE" min-value="0">…</uni-number-field>

<script type="module">
  document.querySelector('#price').formatOptions = {
    style: 'currency',
    currency: 'EUR',
  };
</script>

formatOptions is a property only.

  • formatOptions are Intl.NumberFormat options: style (currency, percent, unit), fraction digits, grouping, signDisplay, currencySign. They are compared by value, so a new object with the same options changes nothing. A percentage’s value is the fraction: 25% is 0.25.
  • The locale is the locale prop, else the language of the input (its nearest lang attribute), else the browser’s. On the server, without locale, the text is formatted for en-US and formatted again after hydration, so pass locale when you render on the server.
  • Other numbering systems. Arabic, Devanagari, Bengali, Han and full-width digits are read too, and text typed in one keeps it after the commit.
  • A new locale or format formats the text again. The text stays formatted while focused: there is no separate editing format.

A unit as a suffix

A format puts the currency sign, % or unit word in the text, where the locale places it, so screen readers read it and typing or pasting it works. When the unit should stay in place while you type, or look apart from the number, put it beside the input instead, as the Deposit field above does:

React

const deposit = useBehavior(numberField, {
  formatOptions: { minimumFractionDigits: 2, maximumFractionDigits: 2 },
  minValue: 0,
});
return (
  <div {...deposit.parts.root}>
    <label {...deposit.parts.label}>Deposit</label>
    <div {...deposit.parts.wrapper}>
      <input {...deposit.parts.input} />
      <span aria-hidden="true">€</span>
    </div>
    <p {...deposit.parts.description}>In euros</p>
  </div>
);

Vue

<script setup lang="ts">
const deposit = useBehavior(numberField, {
  formatOptions: { minimumFractionDigits: 2, maximumFractionDigits: 2 },
  minValue: 0,
});
</script>

<template>
  <div v-bind="deposit.parts.root">
    <label v-bind="deposit.parts.label">Deposit</label>
    <div v-bind="deposit.parts.wrapper">
      <input v-bind="deposit.parts.input" />
      <span aria-hidden="true">€</span>
    </div>
    <p v-bind="deposit.parts.description">In euros</p>
  </div>
</template>

HTML

<uni-number-field id="deposit" min-value="0">
  <div data-uni="number-field">
    <label data-element="Label">Deposit</label>
    <div data-element="Wrapper">
      <input data-element="Input" />
      <span aria-hidden="true">€</span>
    </div>
    <p data-element="Description">In euros</p>
  </div>
</uni-number-field>

<script type="module">
  document.querySelector('#deposit').formatOptions = {
    minimumFractionDigits: 2,
    maximumFractionDigits: 2,
  };
</script>
  • Name the unit where it is read. The suffix is aria-hidden, so put the unit in the label or, as here, the description.
  • The format has no sign, so the input takes digits and separators only: a typed € is refused, as any text that isn’t a number.
  • The place is yours, not the locale’s: a suffix stays a suffix where the locale writes the sign first.
  • With the default styles, the suffix sits inside the box, 4 pixels from the input; the same goes for a prefix.

Controlled value

React

function AmountField({ limit }: { limit: number }) {
  const [amount, setAmount] = useState<number | null>(0);
  const field = useBehavior(numberField, {
    value: amount,
    onValueChange: setAmount,
    minValue: 0,
    maxValue: limit,
    // The limit can drop below a stored amount.
    isInvalid: amount !== null && amount > limit,
  });
  // …
}

Vue

<script setup lang="ts">
const props = defineProps<{ limit: number }>();
const amount = ref<number | null>(0);

const field = useBehavior(numberField, () => ({
  value: amount.value,
  onValueChange: (value) => (amount.value = value),
  minValue: 0,
  maxValue: props.limit,
  // The limit can drop below a stored amount.
  isInvalid: amount.value !== null && amount.value > props.limit,
}));
</script>

<template>
  <!-- … -->
</template>

HTML

<script type="module">
  const amount = document.querySelector('#amount');
  amount.value = 0; // setting the property makes it controlled
  amount.addEventListener('valuechange', (event) => {
    amount.value = event.detail;
  });
</script>

The text shows your value, formatted, and follows it when it changes, even while the input has focus. After a commit or a step, the field reports the new value and keeps showing yours until you answer; a value you don’t take is replaced by yours.

Your value is shown as given, even off the step or out of range: UniAria never changes it without reporting. Commits and steps always land in range, so a value out of range comes from you (a new maxValue, a stored value). Mark it with isInvalid and an error that says why, as above. Leaving the field without editing doesn’t correct it; a step moves it into range.

In a form

Put name on the hiddenInput part, not on the visible input. The form then gets the committed number as plain digits (1234.5, 0.25 for 25%), or an empty string, never the formatted text. The hidden input is disabled with the field, so a disabled field submits nothing.

Enter commits before the form submits, and a press on a submit button commits by moving focus out of the input. On the form’s reset, an uncontrolled field goes back to defaultValue (empty without one), even one set after it rendered, and valueChange reports it. A controlled field reports defaultValue, or without one the value it started with. In a Form, the form holds the number under the field’s name, checks minValue, maxValue and step when focus leaves the input, and writes the name on the hidden input. See Forms.

Other languages

The buttons are named “Increase” and “Decrease” followed by the field’s label, and the input gets the role description “Number field” in English. For another language, pass whole names: they replace the English ones, so the label can go anywhere in them.

const menge = useBehavior(numberField, {
  locale: 'de-DE',
  incrementLabel: 'Menge erhöhen',
  decrementLabel: 'Menge verringern',
  roleDescription: 'Zahlenfeld',
});

Outside English the input has no role description unless you pass one, and a screen reader says its own word for a text box.

Anatomy

Part Element Notes
root any; usually div Optional. The outermost element, around the other parts. No role.
wrapper any; usually div Optional. The box around the input and the buttons. No role. A press on it outside any control focuses the input.
label label by default As Field’s label. It also names the buttons.
input input UniAria writes Field’s id (unless it has one), aria-labelledby, aria-describedby, aria-invalid and aria-required, plus type="text", inputmode, autocomplete="off", autocorrect="off", spellcheck="false", aria-roledescription, disabled, readonly and the value property (the text). You own placeholder, size, autofocus, enterkeyhint and form. Give it no name.
increment button Optional. UniAria writes type="button", tabindex="-1", id (unless it has one), aria-label, aria-labelledby, aria-controls (the input), and aria-disabled while it can’t step. You own the content, such as an icon.
decrement button Optional. As increment.
hiddenInput input type="hidden" Optional. UniAria writes type="hidden", disabled while the field is, and the value property: the committed number, or ''. Put name here.
description any As Field’s description.
error any As Field’s error: hidden until isInvalid.
errorText any, inside error Optional. In a Form, UniAria writes the first error’s text here; leave it out to render errors yourself.

A part keeps an id you give it, and everything that refers to the part follows it; UniAria writes an id only on a part that has none (see Ids).

The parts link as Field’s do. Set disabled and readonly through the props, not on the input: UniAria writes those attributes and removes yours.

Props

Prop Attribute Type Default Description
value / defaultValue default-value; the value property makes it controlled number | null null The committed number; null when empty. Changes come from valueChange.
minValue min-value number — Commits and steps stay at or above it. A minus sign can be typed only when it is unset or below 0.
maxValue max-value number — Commits and steps stay at or below it.
step step number — When set, a commit moves to the nearest step, counted from minValue (or 0). Steps move by it, else by 1, or 1% for percentages.
pageStep page-step number 10 steps What PageUp and PageDown move by.
formatOptions — (property only) Intl.NumberFormatOptions {} How the number is shown and read.
locale locale string the input’s language A BCP 47 tag, such as de-DE.
isWheelDisabled wheel-disabled boolean false Turns off stepping with the wheel.
isDisabled disabled boolean false Native disabled on the input and the hidden input. The buttons can’t step. Marks every part and hides the error.
isReadOnly readonly boolean false Native readonly on the input: no key, button or wheel changes the value.
isRequired required boolean false aria-required="true" on the input, never the native required.
isInvalid invalid boolean — aria-invalid="true" on the input. Shows, links and announces the error. Unset in a Form, the form’s errors decide it.
incrementLabel increment-label string “Increase” and the label The increment button’s whole name.
decrementLabel decrement-label string “Decrease” and the label The decrement button’s whole name.
roleDescription role-description string 'Number field' in English The input’s aria-roledescription. None in other languages unless you pass one.
inputId — string generated The input’s id while the input has none of its own.
labelElementType — (read from the label’s tag) string 'label' The label’s tag; for goes on a label only.
labelledBy labelled-by string — More ids for the input’s aria-labelledby, after the label’s. They also follow the buttons’ default names.
describedBy described-by string — More ids for the input’s aria-describedby, after the description and the error.
tooltip tooltip (the tooltip’s id) a Tooltip’s instance — A Tooltip for the input. Without it, the element links the <uni-tooltip> it sits in directly.

In a Form, NumberField also takes name, form, validate, validationTrigger, validationDelay, validationMessages, dependsOn, isEqual and shouldPreserve: see Field props for the form.

An attribute that isn’t a number, and a step or pageStep of 0 or less, count as unset.

Events

Event Callback prop Detail When
valuechange onValueChange number | null A commit or a step changed the value, or a form reset asks for the default. Never for a value you set.
focuschange onFocusChange boolean The input gained or lost focus.

The custom element dispatches each event on itself, bubbling, with the value in detail. In React, the input’s onFocus, onBlur, onKeyDown, onInput, onPaste, onPointerEnter, onPointerLeave, onCompositionStart and onCompositionEnd come from UniAria, and passing your own replaces them. In Vue, listeners you add next to v-bind run as well as UniAria’s.

State

Field Type Meaning
value number | null The committed value.
canIncrement boolean A step up would change the value: not at the maximum, not read-only, not disabled.
canDecrement boolean The same, down.
isFocused boolean The input has focus.
isFocusVisible boolean The input has focus from a keyboard or a screen reader.
isHovered boolean A mouse or pen is over the input.
isDisabled boolean The isDisabled prop, or the form’s.
isReadOnly boolean The isReadOnly prop, or the form’s.
isRequired boolean The isRequired prop.
isInvalid boolean The isInvalid prop, else in a form whether it holds errors.
isErrorShown boolean The error is shown: invalid and not disabled.

canIncrement and canDecrement follow the text as it is typed: typing 100 with a maximum of 15 makes canIncrement false before the text is committed. Typing, stepping, hovering and focusing never render your component again. To render something from state in React or Vue, select it (in Vue, it is a ref):

const canIncrement = useBehaviorState(quantity, (state) => state.canIncrement);

On the custom element, read element.instance.getState(). See State.

Styling

Attribute On When
data-uni="number-field" root Always.
data-element every other part Always.
data-hovered, data-focused, data-focus-visible, data-disabled, data-readonly, data-invalid, data-required root, wrapper, label, input, description, error As TextField: the input’s hover and focus, and the field’s state.
data-pressed, data-hovered, data-focused, data-focus-visible increment, decrement The button’s own, as Button’s.
data-disabled increment, decrement The button can’t step: at a bound, read-only or disabled.

The error part also gets hidden while it isn’t shown. As on every pressable part, UniAria sets touch-action: pan-x pan-y pinch-zoom on the buttons, so a scroll can cancel a touch press.

Default styles

The optional @uniaria/styles package styles every part, as in the demo above, whose CSS tab holds only the page’s layout. The wrapper is the box, as in TextField, with the number at the input’s end. The steppers are quiet 24-pixel buttons beside the input, in markup order; at the box’s edge, one sits as far from the edge as from the top and bottom. They take the disabled text while they can’t step.

Keyboard

On the input:

Key Behavior
ArrowUp, ArrowDown One step up or down. Held, it repeats with the key.
PageUp, PageDown pageStep up or down, 10 steps by default.
Home, End To minValue, or to the last step at or below maxValue, when that bound is set. Otherwise the caret moves.
Enter Commits the text, then the form submits as usual.
Escape Not handled: it reaches your handlers and closes a dialog around the field.
Tab Moves focus to and from the input. The buttons are out of the Tab order.

Keys with a modifier keep their native meaning (Shift+ArrowUp selects text). No key steps while an input method is composing text, or while the field is read-only or disabled. A key whose default is already prevented is left alone, so you can take a key over: with a keydown listener on the input element, onKeyDownCapture in React, or in Vue a @keydown written before v-bind, which Vue runs first.

On phones, inputmode picks a keyboard that has what can be typed. On an iPhone, whose number keyboards have no minus sign, it is text when a minus sign is allowed, else decimal when the format shows fractions, else numeric. On Android it is numeric when a minus sign is allowed, else decimal or numeric the same way. Elsewhere it is numeric.

Accessibility

  • A text box, not a spin button. The input keeps its text box role, with aria-roledescription="Number field", and has no aria-valuenow: VoiceOver can’t edit a spinbutton’s text, and a text box reads the formatted text as it is. A screen reader reads “Number field, Quantity, 1,024”, or “Number field, Price, €12.50” for a currency. On iOS the role description is left off, so VoiceOver reads the required state.
  • Announcements. A text box’s value changing under it isn’t read, so after a step, and after a commit that changed what was typed (clamped, snapped, rounded or formatted), NumberField announces the new text through UniAria’s live announcer. A minus sign is announced as “minus”. Typing announces nothing: the screen reader already echoes it.
  • Buttons. They are out of the Tab order, since the arrow keys do the same, but touch, voice control and a screen reader’s browse mode reach them. They read “button, Increase Quantity”. At a bound they get aria-disabled and stay focusable, so someone who pressed up to the maximum keeps focus and hears “disabled”.
  • Focus. A mouse press on a button focuses the input. A touch or screen reader press focuses the button, so no on-screen keyboard opens and the reading position stays. If the input already has focus, it keeps it.
  • Errors. A value out of range is yours to report: set isInvalid and write an error that says why, which Field announces and links.

Compared with other libraries

  • React Aria makes the same role choice. NumberField differs where its users noticed: buttons at a bound stay focusable with aria-disabled instead of disabled; PageUp and PageDown move 10 steps, not one; Home and End move the caret when there is no bound; the wheel turned up increments, as in Chromium’s number input; only steps and corrected commits are announced, not each keystroke; empty is null, not NaN; a value you pass is shown as given rather than clamped for display; and there is no commitBehavior: 'validate'.
  • Strings. React Aria ships translations for 34 locales. UniAria has English defaults and props for your own.
  • The APG and Zag use the spinbutton role with aria-valuenow. Zag steps 10 at a time with Shift and an arrow key, has a drag-to-change scrubber and an opt-in wheel; NumberField has none of these.
  • Native type="number" can’t show currencies or grouping, and Firefox and WebKit don’t step it with the wheel. The form still gets a plain number through the hidden input part.