---
title: "NumberField"
description: "An input for a non-clinical number with increment and decrement steppers, built on Base UI NumberField."
url: "https://opsinjs.pensievelabs.org/components/number-field"
source: "https://opsinjs.pensievelabs.org/components/number-field.md"
section: "Components"
status: "shipped"
kind: "component"
category: "actions-and-forms"
reviewed: "2026-09-20"
reviewer: "engineering"
aliases: ["stepper input", "quantity input"]
usedIn: ["diabetes-medicines-app"]
implemented: true
---

> Elements written as `<PascalCase … />` below are opsinjs documentation
> components. Their attributes are the content: the values they render are
> generated from `tokens/*.json` and `registry/catalogue.ts` and are
> published separately at https://opsinjs.pensievelabs.org/r/index.json and under the Reference
> section.
> `<StubNotice>` IS THE EXCEPTION, AND IT IS THE ONE TO READ. It is a
> paired element rather than a self-closing one, and the text between
> its opening and closing tags is prose an author wrote, reproduced
> below word for word. That prose is where this page says whether the
> component has been reviewed. Read the children, not only the
> attributes.

<StubNotice
  name="number-field"
  status="shipped"
  questions="[
  &#x22;No contrast pair has been measured: the hairline, the stepper glyphs, the input ink and the muted disabled ink are argued from their roles, not checked.&#x22;,
  &#x22;Nobody has heard the field in a screen reader, so whether a named spinbutton with two named steppers arrives in the expected order is reasoned, not tested.&#x22;,
  &#x22;A stepper disabled at a bound is drawn in muted ink alone, and whether reaching a bound is announced to assistive technology has not been tested.&#x22;,
]"
>
  Audited against WCAG 2.2 AA. Clinical review pending.
</StubNotice>

## Preview [#preview]

<ComponentPreview name="number-field" />

## Installation [#installation]

<ComponentInstall name="number-field" unbuilt="false" importPath="@/components/ui/number-field" dependencies="[&#x22;@base-ui/react&#x22;, &#x22;lucide-react&#x22;]" />

## Usage [#usage]

```tsx
import { NumberField } from "@/components/ui/number-field"
```

```tsx
<NumberField
  label="Number of copies"
  value={copies}
  onValueChange={setCopies}
  min={1}
  max={10}
  step={1}
/>
```

## When to use it [#when-to-use-it]

<WhenToUse
  use="[
  &#x22;A plain quantity or count with no clinical meaning, such as the number of copies to print.&#x22;,
  &#x22;A value the reader nudges one at a time as readily as types it.&#x22;,
  &#x22;A bounded count where a floor and a ceiling the product owns keep the reader inside a range.&#x22;,
]"
  avoid="[
  { case: &#x22;It is a clinical measurement, which carries a unit, needs an out-of-range warning and is typed in whole.&#x22;, instead: &#x22;reading-input&#x22; },
  { case: &#x22;The number is typed once and never nudged, such as a year or a reference code.&#x22;, instead: &#x22;field&#x22; },
]"
/>

## Anatomy [#anatomy]

<Anatomy
  name="number-field"
  parts="[
  {
    name: &#x22;NumberField&#x22;,
    describes: &#x22;The root, Base UI's NumberField.Root. Owns value, bounds, step and the keyboard contract. On neither colour axis.&#x22;,
    prop: &#x22;value&#x22;,
  },
  {
    name: &#x22;number-field-group&#x22;,
    describes: &#x22;The hairline frame, Base UI's NumberField.Group, holding the three controls as one object.&#x22;,
  },
  {
    name: &#x22;number-field-decrement&#x22;,
    describes: &#x22;A real button named Decrease with a Minus glyph. Floors its target at 44px and disables itself at min.&#x22;,
  },
  {
    name: &#x22;number-field-input&#x22;,
    describes: &#x22;The spinbutton input. label is applied here as aria-label. Centred, tabular-nums, body type step.&#x22;,
    prop: &#x22;label&#x22;,
  },
  {
    name: &#x22;number-field-increment&#x22;,
    describes: &#x22;A real button named Increase with a Plus glyph. Floors its target at 44px and disables itself at max.&#x22;,
  },
]"
/>

<CompositionTree
  name="number-field"
  tree="[
  {
    part: &#x22;NumberField&#x22;,
    cardinality: &#x22;1&#x22;,
    note: &#x22;the root&#x22;,
    children: [
      {
        part: &#x22;number-field-group&#x22;,
        cardinality: &#x22;1&#x22;,
        note: &#x22;the hairline frame&#x22;,
        children: [
          {
            part: &#x22;number-field-decrement&#x22;,
            cardinality: &#x22;1&#x22;,
            note: &#x22;disables at min&#x22;,
          },
          {
            part: &#x22;number-field-input&#x22;,
            cardinality: &#x22;1&#x22;,
            note: &#x22;the spinbutton&#x22;,
          },
          {
            part: &#x22;number-field-increment&#x22;,
            cardinality: &#x22;1&#x22;,
            note: &#x22;disables at max&#x22;,
          },
        ],
      },
    ],
  },
]"
/>

## Examples [#examples]

### A quantity [#a-quantity]

A reader sets how many copies to print, stepping with the buttons or typing.
`label` names the count, which is fictional and carries no unit.

<ComponentPreview name="number-field-a-quantity" kind="example" align="start" />

### With bounds [#with-bounds]

`min` and `max` fix the range and `step` sets how far one press moves. The
decrement button disables at the floor and the increment button at the ceiling.

<ComponentPreview name="number-field-with-bounds" kind="example" align="start" />

## Content guidelines [#content-guidelines]

Name the thing being counted in `label`: *Number of copies*, never a bare
*Number* or a unit. Set `step` to the smallest change the count is measured in,
`1` for a whole count. Set `min` and `max` only where a real floor and ceiling
exist. A count with no upper limit takes no `max`.

<DoDont>
  <DoDont.Do>
    **`label="Number of copies"`, `step={1}`, `min={1}`.** Names the count, steps
    by whole copies, floors at a real bound.
  </DoDont.Do>

  <DoDont.Dont>
    **`label="Systolic"` with a min and max around a reading.** A measurement
    dressed as a count. It is a [ReadingInput](./reading-input.mdx).
  </DoDont.Dont>
</DoDont>

## Accessibility [#accessibility]

**Audited against WCAG 2.2 AA, source and rendered.** The audit is author-run,
not independent, and clinical review is pending. The rendered
pass was clean: no axe violations, focus visible, a 44px target floor, no loss at
320px reflow. The source pass confirmed icons are `aria-hidden`, neither colour
axis nor a colour-only state, role tokens with no `px` type, and Base UI’s own keyboard and names.

**What the audit fixed.** The input could be named only through `aria-label`. The
wrapper now also forwards `id` and `aria-labelledby`, suppressing the redundant
`aria-label` when a visible label is wired so it wins the name and Label in Name
holds. `label` stays the required default, so the change is additive.

**Residual.** The default still names the input through `aria-label`; a
persistent visible label is a recommended enhancement, deferred to one central
repair across the input wrappers. Base UI hard-codes `autoComplete="off"`,
acceptable for a count with no WCAG token, and surfaces no error text, since it
clamps to bounds and owns validation. Every contrast pair, including the disabled
ink at a bound, is unmeasured, so the report below stands in.

<KeyboardTable
  name="number-field"
  rows="[
  {
    keys: &#x22;Tab&#x22;,
    action: &#x22;Moves focus into the field, onto the input&#x22;,
    notes: &#x22;The buttons are reachable with Tab too.&#x22;,
  },
  {
    keys: &#x22;Arrow Up, Arrow Down&#x22;,
    action: &#x22;Steps the value up or down by step&#x22;,
    notes: &#x22;Stops at max going up, min going down.&#x22;,
  },
  {
    keys: &#x22;Page Up, Page Down&#x22;,
    action: &#x22;Steps by a larger amount&#x22;,
    notes: &#x22;Base UI's large step, for crossing a bounded range quickly.&#x22;,
  },
  {
    keys: &#x22;Home, End&#x22;,
    action: &#x22;Jumps to min or max&#x22;,
    notes: &#x22;Only where that bound is set.&#x22;,
  },
  {
    keys: &#x22;Enter&#x22;,
    action: &#x22;Commits the typed value&#x22;,
    notes: &#x22;Inside a form it also submits, the same as any text input.&#x22;,
  },
]"
/>

<ContrastReport component="number-field" />

## API reference [#api-reference]

<PropsTable name="NumberFieldProps" />

`value` and `onValueChange` make this a controlled component with no internal
value state. The caller stores the number, or `null` when the field is empty.
`label` is required, because a spinbutton needs an accessible name. A `min`
above `max` raises a development warning. `min`, `max` and `step` are the
count's own bounds, never a clinical range.

## Related [#related]

* [ReadingInput](./reading-input.mdx) is the control for a clinical measurement,
  with a unit and an out-of-range warning.
* [Field](./field.mdx) with a numeric control is plainer for a number typed once
  and never nudged.
* [Slider](./slider.mdx) is for a value on a continuous track, where position
  matters more than the exact number.
