# Number Field

> Enter or adjust a number with typing, step buttons, or a scrub control.

Source: https://www.honestui.com/docs/components/number-field

```tsx
import {
  NumberField,
  NumberFieldDecrement,
  NumberFieldGroup,
  NumberFieldIncrement,
  NumberFieldInput,
} from "@/components/honest-ui/ui/number-field"

export function NumberFieldDemo() {
  return (
    <NumberField className="w-full max-w-64" defaultValue={0}>
      <NumberFieldGroup>
        <NumberFieldDecrement />
        <NumberFieldInput />
        <NumberFieldIncrement />
      </NumberFieldGroup>
    </NumberField>
  )
}

```

## Overview [#overview]

Use Number Field when people must enter a specific numeric value and benefit from adjusting it without retyping: quantities, limits, prices, percentages, durations, ticket counts. It combines three input styles in one control — type the digits, press the steppers for ±1, or scrub the label for coarse adjustments — so the same field works for precise entry and quick nudges.

For values where only approximate position matters, prefer Slider. For identifiers that merely look numeric (card numbers, codes), use Input with `inputMode="numeric"`, because Number Field parses, formats, and rounds its value as a quantity.

## Anatomy [#anatomy]

A number field has a root, an editable input, decrement and increment buttons grouped around it, an optional scrub area with a visible label, and range constraints (`min`, `max`, `step`) that live on the root. The input displays the formatted value centered with tabular numerals, so digits keep a fixed width while counting up or down.

`NumberFieldScrubArea` requires being inside a `NumberField`; it renders the label text you pass and throws otherwise, because the scrub gesture needs a labeled, associated input to be accessible.

## Behavior [#behavior]

**Typing.** The input accepts digits and locale-appropriate separators while rejecting other characters. Partial values stay as typed until the field blurs, when the value is reformatted and clamped into range. Clearing the field sets the value to `null`, which submits as empty rather than zero — keep those meanings apart in your code.

**Steppers.** The buttons step by `step` (default 1) and disable automatically at the `min`/`max` boundary. Holding <kbd>Shift</kbd> while using arrow keys steps by `largeStep` (10); holding <kbd>Alt</kbd> steps by `smallStep` (0.1).

**Clamping and snapping.** Values beyond the range clamp on blur and on stepper use. `snapOnStep` additionally snaps typed or scrubbed values onto multiples of `step`, which suits inventories and price increments where 0.3 of a unit is meaningless.

**Formatting.** Pass `format` (an `Intl.NumberFormatOptions` object) and `locale` to display currency, percent, or unit styling while the underlying value stays a plain number. Formatting follows the user's runtime locale by default.

**Commit timing.** `onValueChange` fires on every intermediate change; `onValueCommitted` fires when the input blurs, a scrub ends, or a button press releases — do side effects like network calls there.

**Disabled and read-only.** `disabled` dims the whole assembly and blocks all three input styles; `readOnly` keeps the value visible and submittable but rejects edits.

## Accessibility [#accessibility]

The root generates an id for the input and shares it through context, so `NumberFieldScrubArea` can render a `Label` connected via `htmlFor` without extra wiring. Give every field a label — external via `FieldLabel`, or through the scrub-area label — because the steppers carry no text of their own; they rely on Base UI's built-in accessible names ("Increase"/"Decrease") plus the field context.

Keyboard support on the input: <kbd>Arrow Up</kbd>/<kbd>Arrow Down</kbd> step by `step` (<kbd>Shift</kbd> applies `largeStep`, <kbd>Alt</kbd> applies `smallStep`), <kbd>Home</kbd> jumps to `min` and <kbd>End</kbd> jumps to `max` when those bounds are set, and ordinary editing keys behave natively. The stepper buttons are regular focusable buttons with visible focus rings, so keyboard users are never forced through the scrub gesture.

On touch devices an invisible layer expands each stepper's hit area to at least 44 × 44 px, even though the visible button is 24 px square. The scrub area is pointer-driven by design; keyboard and touch users have equivalent paths through typing and steppers, which is why it stays optional.

Setting invalid state (for example `aria-invalid` via `Field`) turns the input border toward the danger color, including on focus. Colors come from `--hui-*` tokens, so borders, backgrounds, and disabled states adapt to dark mode. The layout is direction-aware: the decrement sits at the inline start and the increment at the inline end via logical border radii, so groups mirror correctly under right-to-left locales. Long formatted values widen the input within the group; keep the surrounding column wide enough for the largest plausible value at 200% zoom so nothing truncates mid-edit.

## Installation [#installation]


  

  
    <CliBlock commands="[&#x22;number-field&#x22;]" />
  

  
    
      
        Install the following dependencies:
      

      ```bash
      npm install @base-ui-components/react
      ```

      
        Copy and paste the following code into your project.
      

      ### components/ui/number-field.tsx

```tsx
"use client"

import * as React from "react"
import { NumberField as NumberFieldPrimitive } from "@base-ui-components/react/number-field"
import { Minus as MinusIcon, Plus as PlusIcon } from "honestui/icons"

import { cn } from "@/lib/utils"
import { Label } from "@/components/honest-ui/ui/label"

const NumberFieldContext = React.createContext<{
  fieldId: string
} | null>(null)

function NumberField({
  id,
  className,
  size = "default",
  ...props
}: NumberFieldPrimitive.Root.Props & {
  size?: "sm" | "default" | "lg"
}) {
  const generatedId = React.useId()
  const fieldId = id ?? generatedId

  return (
    <NumberFieldContext.Provider value={{ fieldId }}>
      <NumberFieldPrimitive.Root
        id={fieldId}
        className={cn(
          "flex flex-col items-start gap-[var(--hui-space-2)]",
          className
        )}
        data-slot="number-field"
        data-size={size}
        {...props}
      />
    </NumberFieldContext.Provider>
  )
}

function NumberFieldGroup({
  className,
  ...props
}: NumberFieldPrimitive.Group.Props) {
  return (
    <NumberFieldPrimitive.Group
      className={cn(
        "inline-flex items-center",
        className
      )}
      data-slot="number-field-group"
      {...props}
    />
  )
}

function NumberFieldDecrement({
  className,
  ...props
}: NumberFieldPrimitive.Decrement.Props) {
  return (
    <NumberFieldPrimitive.Decrement
      className={cn(
        "relative flex size-[var(--hui-space-7)] shrink-0 cursor-pointer items-center justify-center rounded-s-[var(--hui-radius-1)] border-[0.5px] border-[var(--hui-color-border-base-tertiary)] bg-[var(--hui-color-background-base-secondary)] p-0 text-[var(--hui-color-foreground-base-primary)] [transition:var(--hui-transition-interactive)] hover:bg-[var(--hui-color-background-base-primary-hover)] focus-visible:[outline:var(--hui-focus-ring)] focus-visible:outline-offset-[var(--hui-focus-ring-offset-inset-border)] not-data-disabled:active:bg-[var(--hui-color-background-neutral-secondary)] data-disabled:pointer-events-none data-disabled:cursor-not-allowed data-disabled:opacity-50 pointer-coarse:after:absolute pointer-coarse:after:size-full pointer-coarse:after:min-h-11 pointer-coarse:after:min-w-11 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
        className
      )}
      data-slot="number-field-decrement"
      {...props}
    >
      <MinusIcon />
    </NumberFieldPrimitive.Decrement>
  )
}

function NumberFieldIncrement({
  className,
  ...props
}: NumberFieldPrimitive.Increment.Props) {
  return (
    <NumberFieldPrimitive.Increment
      className={cn(
        "relative flex size-[var(--hui-space-7)] shrink-0 cursor-pointer items-center justify-center rounded-e-[var(--hui-radius-1)] border-[0.5px] border-[var(--hui-color-border-base-tertiary)] bg-[var(--hui-color-background-base-secondary)] p-0 text-[var(--hui-color-foreground-base-primary)] [transition:var(--hui-transition-interactive)] hover:bg-[var(--hui-color-background-base-primary-hover)] focus-visible:[outline:var(--hui-focus-ring)] focus-visible:outline-offset-[var(--hui-focus-ring-offset-inset-border)] not-data-disabled:active:bg-[var(--hui-color-background-neutral-secondary)] data-disabled:pointer-events-none data-disabled:cursor-not-allowed data-disabled:opacity-50 pointer-coarse:after:absolute pointer-coarse:after:size-full pointer-coarse:after:min-h-11 pointer-coarse:after:min-w-11 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
        className
      )}
      data-slot="number-field-increment"
      {...props}
    >
      <PlusIcon />
    </NumberFieldPrimitive.Increment>
  )
}

function NumberFieldInput({
  className,
  ...props
}: NumberFieldPrimitive.Input.Props) {
  return (
    <NumberFieldPrimitive.Input
      className={cn(
        "h-[var(--hui-space-7)] min-w-0 flex-[1_0_0] border-[0.5px] border-[var(--hui-color-border-base-tertiary)] bg-[var(--hui-color-background-base-primary)] px-[var(--hui-space-2)] text-center text-[var(--hui-color-foreground-base-primary)] tabular-nums outline-none [font-family:var(--hui-font-body)] [font-size:var(--hui-font-size-small)] [font-weight:var(--hui-font-weight-regular)] [letter-spacing:var(--hui-letter-spacing-small)] [line-height:var(--hui-line-height-small)] [transition:var(--hui-transition-interactive)] focus:border-[var(--hui-color-border-accent-emphasis)] focus:bg-[var(--hui-color-background-base-primary)] data-disabled:pointer-events-none data-disabled:cursor-not-allowed data-disabled:opacity-50 data-invalid:border-[var(--hui-color-border-danger-emphasis)] data-invalid:focus:border-[var(--hui-color-border-danger-emphasis-hover)]",
        className
      )}
      data-slot="number-field-input"
      {...props}
    />
  )
}

function NumberFieldScrubArea({
  className,
  label,
  ...props
}: NumberFieldPrimitive.ScrubArea.Props & {
  label: string
}) {
  const context = React.useContext(NumberFieldContext)

  if (!context) {
    throw new Error(
      "NumberFieldScrubArea must be used within a NumberField component for accessibility."
    )
  }

  return (
    <NumberFieldPrimitive.ScrubArea
      className={cn("flex cursor-ew-resize", className)}
      data-slot="number-field-scrub-area"
      {...props}
    >
      <Label htmlFor={context.fieldId} className="cursor-ew-resize">
        {label}
      </Label>
      <NumberFieldPrimitive.ScrubAreaCursor className="drop-shadow-[0_1px_1px_var(--hui-color-overlay-black-a7)] filter">
        <CursorGrowIcon />
      </NumberFieldPrimitive.ScrubAreaCursor>
    </NumberFieldPrimitive.ScrubArea>
  )
}

function CursorGrowIcon(props: React.ComponentProps<"svg">) {
  return (
    <svg
      width="26"
      height="14"
      viewBox="0 0 24 14"
      fill="black"
      stroke="white"
      xmlns="http://www.w3.org/2000/svg"
      {...props}
    >
      <path d="M19.5 5.5L6.49737 5.51844V2L1 6.9999L6.5 12L6.49737 8.5L19.5 8.5V12L25 6.9999L19.5 2V5.5Z" />
    </svg>
  )
}

export {
  NumberField,
  NumberFieldScrubArea,
  NumberFieldDecrement,
  NumberFieldIncrement,
  NumberFieldGroup,
  NumberFieldInput,
}

```

      
        Update the import paths to match your project setup.
      
    
  


## Usage [#usage]

```tsx
import {
  NumberField,
  NumberFieldDecrement,
  NumberFieldGroup,
  NumberFieldIncrement,
  NumberFieldInput,
  NumberFieldScrubArea,
} from "@/components/ui/number-field";
```

```tsx
<NumberField defaultValue={0}>
  <NumberFieldScrubArea label="Quantity" />
  <NumberFieldGroup>
    <NumberFieldDecrement />
    <NumberFieldInput />
    <NumberFieldIncrement />
  </NumberFieldGroup>
</NumberField>
```

Omit the scrub area when the label row would be redundant; wrap the group with an external `FieldLabel` instead. See [With external label](#with-external-label).

## Don't do this [#dont-do-this]

### Using it for identifiers [#using-it-for-identifiers]

```tsx
// Bad
<NumberField name="card" format={{ useGrouping: true }} />

// Good
<Input
  name="card"
  type="text"
  inputMode="numeric"
  autoComplete="cc-number"
/>
```

A card number is not a quantity. Number Field strips leading zeros while parsing, reformats digits into groups, and offers steppers that corrupt the value with one stray click. Identifiers should be plain text fields with a numeric touch keyboard and the matching `autoComplete` token.

### Zero as a stand-in for "no answer" [#zero-as-a-stand-in-for-no-answer]

```tsx
// Bad
<NumberField name="guests" defaultValue={0} />

// Good
<NumberField name="guests" />
```

An empty field means "not answered"; zero means "answered, none". Seeding `defaultValue={0}` silently records zero for everyone who skips the question, polluting averages and counts downstream. Leave the field empty when not answering is legitimate, and handle the `null` value explicitly.

### Re-validating on every keystroke [#re-validating-on-every-keystroke]

```tsx
// Bad
<NumberField
  defaultValue={1}
  onValueChange={(value) => checkAvailability(value)}
/>

// Good
<NumberField
  defaultValue={1}
  onValueCommitted={(value) => checkAvailability(value)}
/>
```

While someone types "12", `onValueChange` reports 1 then 12, firing a request for a quantity nobody wanted. Scrubbing makes this far worse — dozens of intermediate values per second. Wait for `onValueCommitted`, which fires once per completed interaction, and validate the settled value.

## Examples [#examples]

Examples cover sizes, disabled state, external labels, scrub input, ranges, formatting, step values, and form integration.

For accessible labeling and validation, use `Field` to connect the number field with its label, description, and error. See the [Field examples](/docs/components/field#examples).

### Small Size [#small-size]

```tsx
import {
  NumberField,
  NumberFieldDecrement,
  NumberFieldGroup,
  NumberFieldIncrement,
  NumberFieldInput,
} from "@/components/honest-ui/ui/number-field"

export function NumberFieldSm() {
  return (
    <NumberField className="w-full max-w-64" size="sm" defaultValue={0}>
      <NumberFieldGroup>
        <NumberFieldDecrement />
        <NumberFieldInput />
        <NumberFieldIncrement />
      </NumberFieldGroup>
    </NumberField>
  )
}

```

### Large Size [#large-size]

```tsx
import {
  NumberField,
  NumberFieldDecrement,
  NumberFieldGroup,
  NumberFieldIncrement,
  NumberFieldInput,
} from "@/components/honest-ui/ui/number-field"

export function NumberFieldLg() {
  return (
    <NumberField className="w-full max-w-64" size="lg" defaultValue={0}>
      <NumberFieldGroup>
        <NumberFieldDecrement />
        <NumberFieldInput />
        <NumberFieldIncrement />
      </NumberFieldGroup>
    </NumberField>
  )
}

```

### Disabled [#disabled]

```tsx
import {
  NumberField,
  NumberFieldDecrement,
  NumberFieldGroup,
  NumberFieldIncrement,
  NumberFieldInput,
} from "@/components/honest-ui/ui/number-field"

export function NumberFieldDisabled() {
  return (
    <NumberField className="w-full max-w-64" defaultValue={42} disabled>
      <NumberFieldGroup>
        <NumberFieldDecrement />
        <NumberFieldInput />
        <NumberFieldIncrement />
      </NumberFieldGroup>
    </NumberField>
  )
}

```

### With External Label [#with-external-label]

```tsx
import * as React from "react"

import { Label } from "@/components/honest-ui/ui/label"
import {
  NumberField,
  NumberFieldDecrement,
  NumberFieldGroup,
  NumberFieldIncrement,
  NumberFieldInput,
} from "@/components/honest-ui/ui/number-field"

export function NumberFieldWithLabel() {
  const id = React.useId()
  return (
    <div className="w-full max-w-64 flex flex-col items-start gap-2">
      <Label htmlFor={id}>Quantity</Label>
      <NumberField id={id} defaultValue={0}>
        <NumberFieldGroup>
          <NumberFieldDecrement />
          <NumberFieldInput />
          <NumberFieldIncrement />
        </NumberFieldGroup>
      </NumberField>
    </div>
  )
}

```

### With Scrub [#with-scrub]

Drag the label horizontally to change the value; the field stays the source of truth.

```tsx
import {
  NumberField,
  NumberFieldDecrement,
  NumberFieldGroup,
  NumberFieldIncrement,
  NumberFieldInput,
  NumberFieldScrubArea,
} from "@/components/honest-ui/ui/number-field"

export function NumberFieldDemoWithScrub() {
  return (
    <NumberField className="w-full max-w-64" defaultValue={0}>
      <NumberFieldScrubArea label="Quantity" />
      <NumberFieldGroup>
        <NumberFieldDecrement />
        <NumberFieldInput />
        <NumberFieldIncrement />
      </NumberFieldGroup>
    </NumberField>
  )
}

```

### With Range [#with-range]

Values type outside `min`/`max` clamp on blur, and the steppers stop at the boundaries.

```tsx
import {
  NumberField,
  NumberFieldDecrement,
  NumberFieldGroup,
  NumberFieldIncrement,
  NumberFieldInput,
} from "@/components/honest-ui/ui/number-field"

export function NumberFieldWithRange() {
  return (
    <NumberField className="w-full max-w-64" defaultValue={5} min={0} max={10}>
      <NumberFieldGroup>
        <NumberFieldDecrement />
        <NumberFieldInput />
        <NumberFieldIncrement />
      </NumberFieldGroup>
    </NumberField>
  )
}

```

### With Formatted Value [#with-formatted-value]

```tsx
import {
  NumberField,
  NumberFieldDecrement,
  NumberFieldGroup,
  NumberFieldIncrement,
  NumberFieldInput,
} from "@/components/honest-ui/ui/number-field"

export function NumberFieldWithFormattedValue() {
  return (
    <NumberField className="w-full max-w-64"
      defaultValue={0}
      format={{ style: "currency", currency: "USD" }}
    >
      <NumberFieldGroup>
        <NumberFieldDecrement />
        <NumberFieldInput />
        <NumberFieldIncrement />
      </NumberFieldGroup>
    </NumberField>
  )
}

```

### With Step [#with-step]

```tsx
import {
  NumberField,
  NumberFieldDecrement,
  NumberFieldGroup,
  NumberFieldIncrement,
  NumberFieldInput,
  NumberFieldScrubArea,
} from "@/components/honest-ui/ui/number-field"

export function NumberFieldWithStep() {
  return (
    <div className="w-full max-w-64 flex flex-col gap-6">
      <NumberField defaultValue={0} step={10}>
        <NumberFieldScrubArea label="Step 10" />
        <NumberFieldGroup>
          <NumberFieldDecrement />
          <NumberFieldInput />
          <NumberFieldIncrement />
        </NumberFieldGroup>
      </NumberField>
      <NumberField defaultValue={0} step={0.1}>
        <NumberFieldScrubArea label="Step 0.1" />
        <NumberFieldGroup>
          <NumberFieldDecrement />
          <NumberFieldInput />
          <NumberFieldIncrement />
        </NumberFieldGroup>
      </NumberField>
    </div>
  )
}

```

### Form Integration [#form-integration]

```tsx
"use client";

import * as React from "react";
import { z } from "zod";

import { Button } from "@/components/honest-ui/ui/button";
import { Field } from "@/components/honest-ui/ui/field";
import { Form } from "@/components/honest-ui/ui/form";
import {
  NumberField,
  NumberFieldDecrement,
  NumberFieldGroup,
  NumberFieldIncrement,
  NumberFieldInput,
  NumberFieldScrubArea,
} from "@/components/honest-ui/ui/number-field";

const schema = z.object({
  quantity: z.coerce
    .number({ message: "Please enter a quantity." })
    .min(1, { message: "Quantity must be at least 1." })
    .max(100, { message: "Maximum quantity is 100." }),
});

type Errors = Record<string, string | string[]>;

function validateForm(event: React.FormEvent<HTMLFormElement>) {
  event.preventDefault();

  const formData = new FormData(event.currentTarget);
  const result = schema.safeParse(Object.fromEntries(formData.entries()));

  if (!result.success) {
    const { fieldErrors } = z.flattenError(result.error);
    return { errors: fieldErrors as Errors };
  }

  return {
    errors: {} as Errors,
    data: result.data,
  };
}

export function NumberFieldFormDemo() {
  const [status, setStatus] = React.useState("");

  const onSubmit = (event: React.FormEvent<HTMLFormElement>) => {
    const response = validateForm(event);
    if (Object.keys(response.errors).length === 0) {
      setStatus(`Validated quantity: ${response.data?.quantity}.`);
    } else {
      setStatus(String(response.errors.quantity || "Check the quantity."));
    }
  };

  return (
    <Form onSubmit={onSubmit} className="grid w-full max-w-64 gap-4">
      <Field name="quantity">
        <NumberField defaultValue={1} min={1} max={100}>
          <NumberFieldScrubArea label="Quantity" />
          <NumberFieldGroup>
            <NumberFieldDecrement />
            <NumberFieldInput />
            <NumberFieldIncrement />
          </NumberFieldGroup>
        </NumberField>
      </Field>

      <Button type="submit">Save quantity</Button>
      <p className="text-sm text-muted-foreground" role="status">
        {status}
      </p>
    </Form>
  );
}

```

## API reference [#api-reference]

Number Field exports its parts from one file — root, scrub area, scrub cursor, group, decrement, increment, and input — each forwarding its matching Base UI props:

| Part                                            | Purpose                                              |
| ----------------------------------------------- | ---------------------------------------------------- |
| `NumberField`                                   | Root: value, range, step, formatting, disabled state |
| `NumberFieldInput`                              | The editable, formatted text surface                 |
| `NumberFieldGroup`                              | Lays out decrement + input + increment inline        |
| `NumberFieldDecrement` / `NumberFieldIncrement` | Stepper buttons with built-in icons                  |
| `NumberFieldScrubArea`                          | Pointer-drag surface; takes a required `label`       |

Honest UI adds `size` (`"sm"`, `"default"`, `"lg"`) on the root. Put `min`, `max`, `step`, `largeStep`, `smallStep`, `snapOnStep`, `allowWheelScrub`, `format`, `locale`, `required`, `disabled`, and `readOnly` on the root; `value` is `number | null`, where `null` represents an empty field. The scrub area accepts `direction`, `pixelSensitivity`, and `teleportDistance` from Base UI.

See the [Base UI Number Field API](https://base-ui.com/react/components/number-field#api-reference).
