NumberField
An input for a non-clinical number with increment and decrement steppers, built on Base UI NumberField.
Preview
number-field · base base · style base-lyraOpen under the product themeInstallation
pnpm dlx shadcn@latest add @opsinjs/number-field
The @opsinjs namespace is declared in your components.json. Everything it installs is code you then own. There is no runtime package to keep in step.
Usage
import { NumberField } from "@/components/ui/number-field"<NumberField
label="Number of copies"
value={copies}
onValueChange={setCopies}
min={1}
max={10}
step={1}
/>When to use it
Use it when
- A plain quantity or count with no clinical meaning, such as the number of copies to print.
- A value the reader nudges one at a time as readily as types it.
- A bounded count where a floor and a ceiling the product owns keep the reader inside a range.
Do not use it when
- It is a clinical measurement, which carries a unit, needs an out-of-range warning and is typed in whole.Use
reading-inputinstead. - The number is typed once and never nudged, such as a year or a reference code.Use
fieldinstead.
Anatomy
NumberFieldThe root, Base UI's NumberField.Root. Owns value, bounds, step and the keyboard contract. On neither colour axis.Controlled byvaluenumber-field-groupThe hairline frame, Base UI's NumberField.Group, holding the three controls as one object.number-field-decrementA real button named Decrease with a Minus glyph. Floors its target at 44px and disables itself at min.number-field-inputThe spinbutton input. label is applied here as aria-label. Centred, tabular-nums, body type step.Controlled bylabelnumber-field-incrementA real button named Increase with a Plus glyph. Floors its target at 44px and disables itself at max.
- NumberField 1the root
- number-field-group 1the hairline frame
- number-field-decrement 1disables at min
- number-field-input 1the spinbutton
- number-field-increment 1disables at max
- number-field-group 1the hairline frame
Examples
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.
number-field-a-quantity · base base · style base-lyraOpen under the product themeWith 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.
number-field-with-bounds · base base · style base-lyraOpen under the product themeContent 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.
Do
label="Number of copies", step={1}, min={1}. Names the count, steps
by whole copies, floors at a real bound.
Don’t
label="Systolic" with a min and max around a reading. A measurement
dressed as a count. It is a ReadingInput.
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.
| Key | Action | Notes |
|---|---|---|
| Tab | Moves focus into the field, onto the input | The buttons are reachable with Tab too. |
| Arrow Up, Arrow Down | Steps the value up or down by step | Stops at max going up, min going down. |
| Page Up, Page Down | Steps by a larger amount | Base UI's large step, for crossing a bounded range quickly. |
| Home, End | Jumps to min or max | Only where that bound is set. |
| Enter | Commits the typed value | Inside a form it also submits, the same as any text input. |
| Pair | Theme | APCA Lc | WCAG 2.2 | Floor |
|---|---|---|---|---|
| body text on the page | light | 101.6 | 17.18:1 | Pass |
| body text on the page | dark | -100.5 | 18.00:1 | Pass |
| body text on a card | light | 104.7 | 17.96:1 | Pass |
| body text on a card | dark | -99.6 | 16.32:1 | Pass |
| body text on the muted ground | light | 98.1 | 16.32:1 | Pass |
| body text on the muted ground | dark | -97.5 | 13.77:1 | Pass |
| secondary text on the page | light | 83.9 | 7.42:1 | Pass |
| secondary text on the page | dark | -80.7 | 13.44:1 | Pass |
| secondary text on a card | light | 87.0 | 7.76:1 | Pass |
| secondary text on a card | dark | -79.8 | 12.19:1 | Pass |
| secondary text on the muted ground | light | 80.4 | 7.05:1 | Pass |
| secondary text on the muted ground | dark | -77.8 | 10.28:1 | Pass |
| a hairline boundary on the page | light | 19.3 | 1.41:1 | Below floor |
| a hairline boundary on the page | dark | -8.2 | 1.90:1 | Below floor |
| a hairline boundary on a card | light | 22.4 | 1.47:1 | Below floor |
| a hairline boundary on a card | dark | -7.3 | 1.72:1 | Below floor |
| a hairline boundary on the muted ground | light | 15.8 | 1.34:1 | Below floor |
| a hairline boundary on the muted ground | dark | 0.0 | 1.45:1 | Below floor |
| a placeholder boundary on the page | light | 69.8 | 4.52:1 | Pass |
| a placeholder boundary on the page | dark | -51.0 | 7.62:1 | Pass |
| a placeholder boundary on a card | light | 72.8 | 4.72:1 | Pass |
| a placeholder boundary on a card | dark | -50.1 | 6.91:1 | Pass |
| a placeholder boundary on the muted ground | light | 66.2 | 4.29:1 | Pass |
| a placeholder boundary on the muted ground | dark | -48.1 | 5.83:1 | Pass |
| a placeholder fill on the page | light | 8.3 | 1.18:1 | Below floor |
| a placeholder fill on the page | dark | -8.2 | 1.90:1 | Below floor |
| a placeholder fill on a card | light | 11.4 | 1.23:1 | Below floor |
| a placeholder fill on a card | dark | -7.3 | 1.72:1 | Below floor |
| a placeholder fill on the muted ground | light | 0.0 | 1.12:1 | Below floor |
| a placeholder fill on the muted ground | dark | 0.0 | 1.45:1 | Below floor |
| a placeholder boundary on its own fill | light | 58.9 | 3.84:1 | Pass |
| a placeholder boundary on its own fill | dark | -41.5 | 4.01:1 | Below floor |
| the card hairline on the page | light | 69.8 | 4.52:1 | Pass |
| the card hairline on the page | dark | -28.7 | 4.19:1 | Below floor |
| the card hairline on a card | light | 72.8 | 4.72:1 | Pass |
| the card hairline on a card | dark | -27.9 | 3.80:1 | Below floor |
These are the measured token pairs this component draws colour from, not a measurement of the component itself.
API reference
Prop
Type
Generated from NumberFieldProps in registry/bases/base/number-field.tsx.
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
- ReadingInput is the control for a clinical measurement, with a unit and an out-of-range warning.
- Field with a numeric control is plainer for a number typed once and never nudged.
- Slider is for a value on a continuous track, where position matters more than the exact number.