Qure UI
Components

Number Field

A numeric input with steppers, locale-aware formatting and drag-to-change.

'use client'import {  NumberField,  NumberFieldDecrement,  NumberFieldGroup,  NumberFieldIncrement,  NumberFieldInput,  NumberFieldUnit,} from '@/registry/qure/ui/number-field'export default function NumberFieldDemo() {  return (    <NumberField defaultValue={5} min={0} max={40} step={0.5} className="max-w-48">      <NumberFieldGroup>        <NumberFieldInput aria-label="Contrast dose" />        <NumberFieldUnit>mL</NumberFieldUnit>        <NumberFieldDecrement />        <NumberFieldIncrement />      </NumberFieldGroup>    </NumberField>  )}

Installation

npx shadcn@latest add https://qure-ui.qure.ai/r/number-field.json

Usage

import {
  NumberField, NumberFieldDecrement, NumberFieldGroup, NumberFieldIncrement,
  NumberFieldInput, NumberFieldScrubArea, NumberFieldUnit,
} from '@/components/ui/number-field'
<NumberField defaultValue={5} min={0} max={40} step={0.5}>
  <NumberFieldGroup>
    <NumberFieldInput aria-label="Contrast dose" />
    <NumberFieldUnit>mL</NumberFieldUnit>
    <NumberFieldDecrement />
    <NumberFieldIncrement />
  </NumberFieldGroup>
</NumberField>

Reach for this over <Input type="number" /> when the value has a step a user will want to nudge — a dose, a slice thickness, a kVp. For a bare quantity with no natural increment, an Input is less furniture.

Composition

<NumberField>          {/* owns the value, min/max/step, formatting */}
  <NumberFieldGroup>   {/* the bordered shell — border, height, focus ring */}
    <NumberFieldInput />
    <NumberFieldUnit>mL</NumberFieldUnit>
    <NumberFieldDecrement />
    <NumberFieldIncrement />
  </NumberFieldGroup>
</NumberField>

The focus ring is drawn on the group rather than the input, because the steppers live inside the same box; a ring on the input alone would draw a line straight through them.

NumberFieldUnit is ours, not Base UI's — a plain aria-hidden span, because the unit is already in the label and repeating it is noise for a screen reader.

Size

'use client'import {  NumberField,  NumberFieldDecrement,  NumberFieldGroup,  NumberFieldIncrement,  NumberFieldInput,} from '@/registry/qure/ui/number-field'export default function NumberFieldSize() {  return (    <div className="flex w-full max-w-48 flex-col gap-4">      {(['sm', 'md', 'lg'] as const).map((size) => (        <NumberField key={size} size={size} defaultValue={3} min={0}>          <NumberFieldGroup>            <NumberFieldInput aria-label={`Slice thickness (${size})`} />            <NumberFieldDecrement />            <NumberFieldIncrement />          </NumberFieldGroup>        </NumberField>      ))}    </div>  )}

Heights 32, 40 and 48, the same Figma field scale as Input, so a number field and a text field on one row line up. The size goes on the root, in the same place as Input's.

Formatting

'use client'import {  NumberField,  NumberFieldDecrement,  NumberFieldGroup,  NumberFieldIncrement,  NumberFieldInput,} from '@/registry/qure/ui/number-field'export default function NumberFieldFormat() {  return (    <div className="flex w-full max-w-56 flex-col gap-4">      {/* Dose length product, always to one decimal place. */}      <NumberField        defaultValue={412.5}        step={0.1}        smallStep={0.01}        format={{ minimumFractionDigits: 1, maximumFractionDigits: 1 }}      >        <NumberFieldGroup>          <NumberFieldInput aria-label="Dose length product in mGy·cm" />          <NumberFieldDecrement />          <NumberFieldIncrement />        </NumberFieldGroup>      </NumberField>      {/* Reader agreement, entered as a percentage. */}      <NumberField defaultValue={0.92} step={0.01} min={0} max={1} format={{ style: 'percent' }}>        <NumberFieldGroup>          <NumberFieldInput aria-label="Reader agreement" />          <NumberFieldDecrement />          <NumberFieldIncrement />        </NumberFieldGroup>      </NumberField>    </div>  )}

format is an Intl.NumberFormatOptions object, applied on blur and reversed on focus, so the user always edits the raw number and always reads the formatted one. style: 'percent' means the underlying value is a fraction — 0.92 displays as 92%, and step is in fractions too.

smallStep (alt, default 0.1) and largeStep (shift, default 10) come free with the keyboard.

Drag to change

Window level (HU)
'use client'import {  NumberField,  NumberFieldGroup,  NumberFieldInput,  NumberFieldScrubArea,} from '@/registry/qure/ui/number-field'export default function NumberFieldScrub() {  return (    <NumberField defaultValue={-600} min={-1024} max={3071} step={10} largeStep={100} className="max-w-56">      {/* Drag sideways over the label to change the value. */}      <NumberFieldScrubArea className="mb-2 text-sm font-medium">Window level (HU)</NumberFieldScrubArea>      <NumberFieldGroup>        <NumberFieldInput aria-label="Window level in Hounsfield units" />      </NumberFieldGroup>    </NumberField>  )}

NumberFieldScrubArea turns any element into a horizontal drag handle for the value — a label is the usual choice. It is a shortcut, never the only way in: the input is still typeable and the arrow keys still work.

Inside a Field

Protocol range is 80–140 kVp.

'use client'import { Field, FieldDescription, FieldError, FieldLabel } from '@/registry/qure/ui/field'import {  NumberField,  NumberFieldDecrement,  NumberFieldGroup,  NumberFieldIncrement,  NumberFieldInput,  NumberFieldUnit,} from '@/registry/qure/ui/number-field'export default function NumberFieldField() {  return (    <Field name="kvp" className="max-w-64" validationMode="onChange">      <FieldLabel>Tube voltage</FieldLabel>      {/* allowOutOfRange lets a typed value sit outside min/max long enough for          the browser's own range validity to fire. Stepping still clamps. */}      <NumberField defaultValue={120} min={80} max={140} step={10} allowOutOfRange required>        <NumberFieldGroup>          <NumberFieldInput />          <NumberFieldUnit>kVp</NumberFieldUnit>          <NumberFieldDecrement />          <NumberFieldIncrement />        </NumberFieldGroup>      </NumberField>      <FieldDescription>Protocol range is 80–140 kVp.</FieldDescription>      <FieldError match="rangeOverflow">Above the protocol maximum of 140 kVp.</FieldError>      <FieldError match="rangeUnderflow">Below the protocol minimum of 80 kVp.</FieldError>    </Field>  )}

data-invalid reaches the group from the surrounding Field, so protocol limits can be expressed as min and max and reported with two different sentences by match="rangeUnderflow" and match="rangeOverflow".

Stepping always clamps to min/max. Typing does not, if you pass allowOutOfRange — which is what makes range validation possible at all, since a value that cannot leave the range can never be out of it.

API Reference

Everything Base UI's Number Field accepts, plus:

Prop

Type

The ones you will reach for on the root:

Prop

Type

On this page