# Number input

> A number field on Vercel's UI SDK with steppers that repeat while held, currency, percent and unit formats in any locale, and a drag-to-scrub handle.

Canonical: https://hextaui.com/docs/ui-sdk-number-input
Last updated: 2026-11-14

> **Beta.** Built on Vercel’s UI SDK 0.1.0-beta.2 (`@ui-sdk/react`), a public beta. Its API may change.

```tsx title="components/examples/ui-sdk-number-input/demo.tsx"
import { Label } from "@/components/ui/label"
import {
  NumberInput,
  NumberInputControl,
  NumberInputDecrement,
  NumberInputGroup,
  NumberInputIncrement,
  NumberInputScrubArea,
  NumberInputScrubAreaCursor,
} from "@/components/ui/ui-sdk-number-input"

function UiSdkNumberInputDemo() {
  return (
    <NumberInput defaultValue={1} min={1} max={99} className="max-w-36">
      <NumberInputScrubArea pixelsPerStep={6}>
        <Label htmlFor="demo-quantity">Quantity</Label>
        <NumberInputScrubAreaCursor />
      </NumberInputScrubArea>
      <NumberInputGroup>
        <NumberInputDecrement />
        <NumberInputControl id="demo-quantity" />
        <NumberInputIncrement />
      </NumberInputGroup>
    </NumberInput>
  )
}

export { UiSdkNumberInputDemo }
```

## Installation

### CLI

```bash
npx shadcn@latest add https://hextaui.com/r/ui-sdk-number-input.json
```

This adds the component, the HextaUI theme tokens and any HextaUI components it depends on.

### Manual

Add the HextaUI theme tokens (https://hextaui.com/docs/installation#theme) to your global CSS if you haven't yet, then install the dependencies.

```bash
pnpm add @ui-sdk/react@0.1.0-beta.2 @ui-sdk/core@0.1.0-beta.2 @tabler/icons-react class-variance-authority cn
```

Copy and paste the following code into your project.

```tsx title="components/ui/ui-sdk-number-input.tsx"
"use client"

import * as React from "react"
import {
  IconChevronDown,
  IconChevronUp,
  IconMinus,
  IconPlus,
  IconSelector,
} from "@tabler/icons-react"
import * as NumberInputPrimitive from "@ui-sdk/react/number-input"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"

type ClassName<State> =
  string | ((state: State) => string | undefined) | undefined

function mergeClassName<State>(base: string, className: ClassName<State>) {
  return typeof className === "function"
    ? (state: State) => cn(base, className(state))
    : cn(base, className)
}

type NumberInputGroupVariant = "split" | "stacked" | "plain"

const NumberInputInvalidContext = React.createContext(false)
const NumberInputGroupContext =
  React.createContext<NumberInputGroupVariant>("split")

type NumberInputProps = React.ComponentProps<
  typeof NumberInputPrimitive.NumberInput
> & {
  invalid?: boolean
}

function NumberInput({ className, invalid = false, ...props }: NumberInputProps) {
  return (
    <NumberInputInvalidContext.Provider value={invalid}>
      <NumberInputPrimitive.NumberInput
        data-invalid={invalid ? "" : undefined}
        className={mergeClassName(
          "group/number-input flex w-full min-w-0 flex-col gap-1.5",
          className
        )}
        {...props}
      />
    </NumberInputInvalidContext.Provider>
  )
}

const numberInputGroupVariants = cva(
  "group/number-input-group relative flex h-9 w-full min-w-0 items-center rounded-md bg-transparent text-sm text-foreground inset-ring-(length:--hairline) inset-ring-input transition-[background-color,box-shadow] duration-150 ease-out-cubic group-data-disabled/number-input:cursor-not-allowed group-data-disabled/number-input:opacity-50 group-data-invalid/number-input:inset-ring-destructive group-data-readonly/number-input:bg-muted/40 has-[input:focus-visible]:ring-3 has-[input:focus-visible]:ring-focus-ring has-[input:focus-visible]:inset-ring-ring has-[input:user-invalid]:inset-ring-destructive group-data-invalid/number-input:has-[input:focus-visible]:ring-destructive/20 group-data-invalid/number-input:has-[input:focus-visible]:inset-ring-destructive data-scrubbing:inset-ring-ring motion-reduce:transition-none dark:bg-input/30 dark:group-data-readonly/number-input:bg-input/20 forced-colors:border pointer-coarse:h-10 [@media(hover:hover)]:hover:not-has-[input:focus-visible]:not-group-data-disabled/number-input:not-group-data-invalid/number-input:inset-ring-ring/70",
  {
    variants: {
      variant: {
        split: "gap-0.5 px-1",
        stacked: "gap-1 ps-0 pe-0.5",
        plain: "gap-1 px-0",
      },
    },
    defaultVariants: {
      variant: "split",
    },
  }
)

function NumberInputGroup({
  className,
  variant = "split",
  ...props
}: React.ComponentProps<typeof NumberInputPrimitive.NumberInputGroup> &
  VariantProps<typeof numberInputGroupVariants>) {
  const resolved = variant ?? "split"
  return (
    <NumberInputGroupContext.Provider value={resolved}>
      <NumberInputPrimitive.NumberInputGroup
        data-variant={resolved}
        className={mergeClassName(
          numberInputGroupVariants({ variant: resolved }),
          className
        )}
        {...props}
      />
    </NumberInputGroupContext.Provider>
  )
}

function NumberInputControl({
  className,
  ...props
}: React.ComponentProps<typeof NumberInputPrimitive.NumberInputControl>) {
  const invalid = React.useContext(NumberInputInvalidContext)
  return (
    <NumberInputPrimitive.NumberInputControl
      aria-invalid={invalid || undefined}
      className={mergeClassName(
        "h-full w-full min-w-0 flex-1 bg-transparent px-2 tabular-nums outline-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground disabled:cursor-not-allowed group-data-[variant=plain]/number-input-group:px-3 group-data-[variant=split]/number-input-group:text-center group-data-[variant=stacked]/number-input-group:ps-3 pointer-coarse:text-touch",
        className
      )}
      {...props}
    />
  )
}

const stepButtonClassName =
  "relative inline-flex shrink-0 items-center justify-center text-muted-foreground transition-[color,background-color,scale,opacity] duration-150 ease-out-quint outline-none select-none after:absolute hover:bg-muted hover:text-foreground active:duration-75 data-disabled:pointer-events-none data-disabled:opacity-40 group-data-readonly/number-input:opacity-40 motion-safe:active:scale-90 dark:hover:bg-muted/60 [&_svg]:pointer-events-none [&_svg]:shrink-0 group-data-[variant=split]/number-input-group:size-7 group-data-[variant=split]/number-input-group:rounded-[calc(var(--radius-md)-var(--spacing))] group-data-[variant=split]/number-input-group:after:-inset-1 group-data-[variant=split]/number-input-group:[&_svg]:size-4 pointer-coarse:group-data-[variant=split]/number-input-group:size-8 group-data-[variant=stacked]/number-input-group:h-1/2 group-data-[variant=stacked]/number-input-group:w-6 group-data-[variant=stacked]/number-input-group:rounded-sm group-data-[variant=stacked]/number-input-group:[&_svg]:size-3.5"

function StepIcon({ direction }: { direction: "up" | "down" }) {
  const variant = React.useContext(NumberInputGroupContext)
  if (variant === "stacked") {
    return direction === "up" ? (
      <IconChevronUp aria-hidden="true" />
    ) : (
      <IconChevronDown aria-hidden="true" />
    )
  }
  return direction === "up" ? (
    <IconPlus aria-hidden="true" />
  ) : (
    <IconMinus aria-hidden="true" />
  )
}

function NumberInputIncrement({
  className,
  children,
  "aria-label": ariaLabel = "Increase",
  ...props
}: React.ComponentProps<typeof NumberInputPrimitive.NumberInputIncrement>) {
  return (
    <NumberInputPrimitive.NumberInputIncrement
      aria-label={ariaLabel}
      className={mergeClassName(stepButtonClassName, className)}
      {...props}
    >
      {children ?? <StepIcon direction="up" />}
    </NumberInputPrimitive.NumberInputIncrement>
  )
}

function NumberInputDecrement({
  className,
  children,
  "aria-label": ariaLabel = "Decrease",
  ...props
}: React.ComponentProps<typeof NumberInputPrimitive.NumberInputDecrement>) {
  return (
    <NumberInputPrimitive.NumberInputDecrement
      aria-label={ariaLabel}
      className={mergeClassName(stepButtonClassName, className)}
      {...props}
    >
      {children ?? <StepIcon direction="down" />}
    </NumberInputPrimitive.NumberInputDecrement>
  )
}

function NumberInputStepper({
  className,
  ...props
}: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="number-input-stepper"
      className={cn(
        "flex h-full shrink-0 flex-col justify-center py-0.5",
        className
      )}
      {...props}
    />
  )
}

function NumberInputScrubArea({
  className,
  ...props
}: React.ComponentProps<typeof NumberInputPrimitive.NumberInputScrubArea>) {
  return (
    <NumberInputPrimitive.NumberInputScrubArea
      className={mergeClassName(
        "inline-flex w-fit shrink-0 cursor-ew-resize items-center gap-1.5 select-none data-[orientation=vertical]:cursor-ns-resize group-data-disabled/number-input:cursor-not-allowed group-data-readonly/number-input:cursor-default group-data-[variant=plain]/number-input-group:h-full group-data-[variant=plain]/number-input-group:min-w-8 group-data-[variant=plain]/number-input-group:justify-center group-data-[variant=plain]/number-input-group:rounded-s-md group-data-[variant=plain]/number-input-group:ps-1 group-data-[variant=plain]/number-input-group:text-xs group-data-[variant=plain]/number-input-group:font-medium group-data-[variant=plain]/number-input-group:text-muted-foreground group-data-[variant=plain]/number-input-group:transition-colors group-data-[variant=plain]/number-input-group:hover:text-foreground group-data-[variant=plain]/number-input-group:data-scrubbing:text-foreground",
        className
      )}
      {...props}
    />
  )
}

function NumberInputScrubAreaCursor({
  className,
  children,
  ...props
}: React.ComponentProps<
  typeof NumberInputPrimitive.NumberInputScrubAreaCursor
>) {
  return (
    <NumberInputPrimitive.NumberInputScrubAreaCursor
      className={mergeClassName(
        "z-50 -ms-2.5 -mt-2.5 flex size-5 items-center justify-center text-foreground drop-shadow-[0_0_1px_var(--color-background)] [&_svg]:size-5",
        className
      )}
      {...props}
    >
      {children ?? <IconSelector aria-hidden="true" className="rotate-90" />}
    </NumberInputPrimitive.NumberInputScrubAreaCursor>
  )
}

const createNumberInputController =
  NumberInputPrimitive.createNumberInputController

export {
  NumberInput,
  NumberInputGroup,
  NumberInputControl,
  NumberInputIncrement,
  NumberInputDecrement,
  NumberInputStepper,
  NumberInputScrubArea,
  NumberInputScrubAreaCursor,
  createNumberInputController,
  numberInputGroupVariants,
}
export type { NumberInputProps }
export type {
  NumberInputController,
  NumberInputState,
} from "@ui-sdk/react/number-input"
```

Update the import paths to match your project setup.

## Usage

UI SDK adds a little CSS of its own in a cascade layer named `ui-sdk`. Declare that layer at the top of your global stylesheet, above the Tailwind import, so your classes win over it.

```css
@layer ui-sdk;

@import "tailwindcss";
```

```tsx
import {
  NumberInput,
  NumberInputControl,
  NumberInputDecrement,
  NumberInputGroup,
  NumberInputIncrement,
} from "@/components/ui/ui-sdk-number-input"
```

```tsx
<NumberInput defaultValue={1} min={0}>
  <NumberInputGroup>
    <NumberInputDecrement />
    <NumberInputControl aria-label="Quantity" />
    <NumberInputIncrement />
  </NumberInputGroup>
</NumberInput>
```

The value is a number, or `null` when the field is empty, while the field shows it formatted. Typing updates the value once the text is a valid number. Enter or leaving the field commits it: the text is read, kept within `min` and `max`, snapped to the step and formatted again.

## Composition

```text
NumberInput
├── NumberInputScrubArea
│   └── NumberInputScrubAreaCursor
└── NumberInputGroup
    ├── NumberInputDecrement
    ├── NumberInputControl
    ├── NumberInputIncrement
    └── NumberInputStepper
        ├── NumberInputIncrement
        └── NumberInputDecrement
```

## Examples

### Min and max

A step button turns off at its bound. A typed value outside the bounds is pulled back in when it’s committed.

```tsx title="components/examples/ui-sdk-number-input/min-max.tsx"
import { Label } from "@/components/ui/label"
import {
  NumberInput,
  NumberInputControl,
  NumberInputDecrement,
  NumberInputGroup,
  NumberInputIncrement,
} from "@/components/ui/ui-sdk-number-input"

function UiSdkNumberInputMinMax() {
  return (
    <div className="flex w-full max-w-40 flex-col gap-2">
      <Label htmlFor="guests">Guests</Label>
      <NumberInput defaultValue={2} min={1} max={8}>
        <NumberInputGroup>
          <NumberInputDecrement />
          <NumberInputControl id="guests" />
          <NumberInputIncrement />
        </NumberInputGroup>
      </NumberInput>
      <p className="m-0 text-xs text-muted-foreground">
        Tables seat 1 to 8 guests.
      </p>
    </div>
  )
}

export { UiSdkNumberInputMinMax }
```

### Step

`step` is the usual increment. Shift, Page Up and Page Down move by `largeStep`, and Alt or Option with an arrow key by `smallStep`. Hold a button to repeat.

```tsx title="components/examples/ui-sdk-number-input/step.tsx"
import { Label } from "@/components/ui/label"
import {
  NumberInput,
  NumberInputControl,
  NumberInputDecrement,
  NumberInputGroup,
  NumberInputIncrement,
} from "@/components/ui/ui-sdk-number-input"

function UiSdkNumberInputStep() {
  return (
    <div className="grid w-full max-w-sm grid-cols-2 gap-4">
      <div className="flex flex-col gap-2">
        <Label htmlFor="temperature">Temperature</Label>
        <NumberInput
          defaultValue={0.7}
          min={0}
          max={2}
          step={0.1}
          largeStep={0.5}
          smallStep={0.01}
        >
          <NumberInputGroup>
            <NumberInputDecrement />
            <NumberInputControl id="temperature" />
            <NumberInputIncrement />
          </NumberInputGroup>
        </NumberInput>
      </div>
      <div className="flex flex-col gap-2">
        <Label htmlFor="max-tokens">Max tokens</Label>
        <NumberInput
          defaultValue={4096}
          min={256}
          max={32768}
          step={256}
          largeStep={4096}
        >
          <NumberInputGroup>
            <NumberInputDecrement />
            <NumberInputControl id="max-tokens" />
            <NumberInputIncrement />
          </NumberInputGroup>
        </NumberInput>
      </div>
    </div>
  )
}

export { UiSdkNumberInputStep }
```

### Currency, percent and units

`format` takes `Intl.NumberFormat` options. People can type in the same format, and a percent is a fraction, so 15% is 0.15. The `variant="stacked"` group puts small chevrons at the end.

```tsx title="components/examples/ui-sdk-number-input/format.tsx"
import { Label } from "@/components/ui/label"
import {
  NumberInput,
  NumberInputControl,
  NumberInputDecrement,
  NumberInputGroup,
  NumberInputIncrement,
  NumberInputStepper,
} from "@/components/ui/ui-sdk-number-input"

const fields = [
  {
    id: "price",
    label: "Price",
    defaultValue: 19.99,
    step: 0.01,
    format: { style: "currency", currency: "USD" },
  },
  {
    id: "discount",
    label: "Discount",
    defaultValue: 0.15,
    step: 0.01,
    format: { style: "percent" },
  },
  {
    id: "weight",
    label: "Weight",
    defaultValue: 2.5,
    step: 0.1,
    format: { style: "unit", unit: "kilogram" },
  },
] satisfies {
  id: string
  label: string
  defaultValue: number
  step: number
  format: Intl.NumberFormatOptions
}[]

function UiSdkNumberInputFormat() {
  return (
    <div className="flex w-full max-w-44 flex-col gap-4">
      {fields.map(({ id, label, ...options }) => (
        <div key={id} className="flex flex-col gap-2">
          <Label htmlFor={id}>{label}</Label>
          <NumberInput {...options} min={0} locale="en-US">
            <NumberInputGroup variant="stacked">
              <NumberInputControl id={id} />
              <NumberInputStepper>
                <NumberInputIncrement />
                <NumberInputDecrement />
              </NumberInputStepper>
            </NumberInputGroup>
          </NumberInput>
        </div>
      ))}
    </div>
  )
}

export { UiSdkNumberInputFormat }
```

### Locale

`locale` picks separators, symbols and digit grouping, for showing and for typing. Without it, the field follows the page’s language once it’s interactive, and the server renders English.

```tsx title="components/examples/ui-sdk-number-input/locale.tsx"
import { Label } from "@/components/ui/label"
import {
  NumberInput,
  NumberInputControl,
  NumberInputDecrement,
  NumberInputGroup,
  NumberInputIncrement,
} from "@/components/ui/ui-sdk-number-input"

const fields = [
  {
    id: "preis",
    label: "Preis",
    locale: "de-DE",
    defaultValue: 1234.5,
    step: 0.01,
    format: { style: "currency", currency: "EUR" },
  },
  {
    id: "budget",
    label: "Budget",
    locale: "en-IN",
    defaultValue: 1234567,
    step: 1000,
    format: { style: "currency", currency: "INR", maximumFractionDigits: 0 },
  },
  {
    id: "kakaku",
    label: "価格",
    locale: "ja-JP",
    defaultValue: 2980,
    step: 10,
    format: { style: "currency", currency: "JPY" },
  },
] satisfies {
  id: string
  label: string
  locale: string
  defaultValue: number
  step: number
  format: Intl.NumberFormatOptions
}[]

function UiSdkNumberInputLocale() {
  return (
    <div className="flex w-full max-w-52 flex-col gap-4">
      {fields.map(({ id, label, ...options }) => (
        <div key={id} className="flex flex-col gap-2">
          <Label htmlFor={id}>{label}</Label>
          <NumberInput {...options} min={0}>
            <NumberInputGroup>
              <NumberInputDecrement />
              <NumberInputControl id={id} />
              <NumberInputIncrement />
            </NumberInputGroup>
          </NumberInput>
        </div>
      ))}
    </div>
  )
}

export { UiSdkNumberInputLocale }
```

### Scrub

Drag a `NumberInputScrubArea` sideways to change the value, like in a design tool. Where the browser allows pointer lock, the drag can go past the edge of the screen, and `NumberInputScrubAreaCursor` draws the cursor.

```tsx title="components/examples/ui-sdk-number-input/scrub.tsx"
import {
  NumberInput,
  NumberInputControl,
  NumberInputGroup,
  NumberInputScrubArea,
  NumberInputScrubAreaCursor,
} from "@/components/ui/ui-sdk-number-input"

const fields = [
  { label: "X position", handle: "X", defaultValue: 120 },
  { label: "Y position", handle: "Y", defaultValue: 48 },
  { label: "Width", handle: "W", defaultValue: 320, min: 0 },
  { label: "Height", handle: "H", defaultValue: 240, min: 0 },
]

function UiSdkNumberInputScrub() {
  return (
    <div
      role="group"
      aria-labelledby="scrub-frame"
      className="flex w-full max-w-64 flex-col gap-2"
    >
      <p id="scrub-frame" className="m-0 text-xs font-medium">
        Frame
      </p>
      <div className="grid grid-cols-2 gap-2">
        {fields.map(({ label, handle, ...options }) => (
          <NumberInput key={label} {...options}>
            <NumberInputGroup variant="plain">
              <NumberInputScrubArea pixelsPerStep={1}>
                <span aria-hidden="true">{handle}</span>
                <NumberInputScrubAreaCursor />
              </NumberInputScrubArea>
              <NumberInputControl aria-label={label} />
            </NumberInputGroup>
          </NumberInput>
        ))}
      </div>
    </div>
  )
}

export { UiSdkNumberInputScrub }
```

### Mouse wheel

With `wheel`, scrolling over the focused field steps the value. It’s off by default, so a page that scrolls past the field never changes it.

```tsx title="components/examples/ui-sdk-number-input/wheel.tsx"
import { Label } from "@/components/ui/label"
import {
  NumberInput,
  NumberInputControl,
  NumberInputDecrement,
  NumberInputGroup,
  NumberInputIncrement,
} from "@/components/ui/ui-sdk-number-input"

function UiSdkNumberInputWheel() {
  return (
    <div className="flex w-full max-w-40 flex-col gap-2">
      <Label htmlFor="hover-delay">Hover delay</Label>
      <NumberInput
        defaultValue={300}
        min={0}
        max={2000}
        step={50}
        wheel
        format={{ style: "unit", unit: "millisecond" }}
        locale="en-US"
      >
        <NumberInputGroup>
          <NumberInputDecrement />
          <NumberInputControl id="hover-delay" />
          <NumberInputIncrement />
        </NumberInputGroup>
      </NumberInput>
      <p className="m-0 text-xs text-muted-foreground">
        Focus it, then scroll over it.
      </p>
    </div>
  )
}

export { UiSdkNumberInputWheel }
```

### Controlled

`onValueChange` runs on every valid change, and `onValueCommit` once an interaction ends, which is the one to save on. A controller steps or sets the value from any button.

```tsx title="components/examples/ui-sdk-number-input/controlled.tsx"
"use client"

import * as React from "react"

import { Button } from "@/components/ui/button"
import { Label } from "@/components/ui/label"
import {
  NumberInput,
  NumberInputControl,
  NumberInputDecrement,
  NumberInputGroup,
  NumberInputIncrement,
  createNumberInputController,
} from "@/components/ui/ui-sdk-number-input"

function UiSdkNumberInputControlled() {
  const [seats, setSeats] = React.useState<number | null>(5)
  const [saved, setSaved] = React.useState<number | null>(5)
  const [controller] = React.useState(createNumberInputController)

  return (
    <div className="flex w-full max-w-56 flex-col gap-2">
      <Label htmlFor="seats">Seats</Label>
      <NumberInput
        controller={controller}
        value={seats}
        min={1}
        max={50}
        onValueChange={(value) => setSeats(value)}
        onValueCommit={(value) => setSaved(value)}
      >
        <NumberInputGroup>
          <NumberInputDecrement />
          <NumberInputControl id="seats" />
          <NumberInputIncrement />
        </NumberInputGroup>
      </NumberInput>
      <div className="flex items-center gap-2 pt-1">
        <Button
          size="sm"
          variant="outline"
          onClick={() => controller.increment({ large: true })}
        >
          Add 10
        </Button>
        <Button
          size="sm"
          variant="ghost"
          onClick={() => controller.setValue(1)}
        >
          Reset to 1
        </Button>
      </div>
      <p className="m-0 text-xs text-muted-foreground tabular-nums">
        {`Showing ${seats ?? "nothing"}, saved ${saved ?? "nothing"}.`}
      </p>
    </div>
  )
}

export { UiSdkNumberInputControlled }
```

### Form

With a `name`, the number submits as is, like 1250, never formatted. `required` blocks an empty field, and a form reset returns to the default.

```tsx title="components/examples/ui-sdk-number-input/form.tsx"
"use client"

import * as React from "react"

import { Button } from "@/components/ui/button"
import { Label } from "@/components/ui/label"
import {
  NumberInput,
  NumberInputControl,
  NumberInputDecrement,
  NumberInputGroup,
  NumberInputIncrement,
} from "@/components/ui/ui-sdk-number-input"

function UiSdkNumberInputForm() {
  const [submitted, setSubmitted] = React.useState<string | null>(null)

  return (
    <form
      className="flex w-full max-w-48 flex-col gap-2"
      onSubmit={(event) => {
        event.preventDefault()
        setSubmitted(String(new FormData(event.currentTarget).get("amount")))
      }}
      onReset={() => setSubmitted(null)}
    >
      <Label htmlFor="amount">Amount</Label>
      <NumberInput
        name="amount"
        required
        min={1}
        step={0.01}
        defaultValue={1250}
        locale="en-US"
        format={{ style: "currency", currency: "USD" }}
      >
        <NumberInputGroup>
          <NumberInputDecrement />
          <NumberInputControl id="amount" />
          <NumberInputIncrement />
        </NumberInputGroup>
      </NumberInput>
      <div className="flex items-center gap-3 pt-1">
        <Button type="submit" size="sm">
          Submit
        </Button>
        <Button type="reset" size="sm" variant="ghost">
          Reset
        </Button>
      </div>
      <p className="m-0 text-xs text-muted-foreground tabular-nums">
        {submitted === null
          ? "Nothing submitted yet."
          : `Submitted amount=${submitted}`}
      </p>
    </form>
  )
}

export { UiSdkNumberInputForm }
```

### Invalid

`invalid` marks the field and sets `aria-invalid` on the input, for checks of your own like stock.

```tsx title="components/examples/ui-sdk-number-input/invalid.tsx"
"use client"

import * as React from "react"

import { Label } from "@/components/ui/label"
import {
  NumberInput,
  NumberInputControl,
  NumberInputDecrement,
  NumberInputGroup,
  NumberInputIncrement,
} from "@/components/ui/ui-sdk-number-input"

const stock = 12

function UiSdkNumberInputInvalid() {
  const [quantity, setQuantity] = React.useState<number | null>(14)
  const invalid = quantity !== null && quantity > stock

  return (
    <div className="flex w-full max-w-40 flex-col gap-2">
      <Label htmlFor="order-quantity">Quantity</Label>
      <NumberInput
        defaultValue={14}
        min={1}
        invalid={invalid}
        onValueChange={(value) => setQuantity(value)}
      >
        <NumberInputGroup>
          <NumberInputDecrement />
          <NumberInputControl
            id="order-quantity"
            aria-describedby="order-quantity-message"
          />
          <NumberInputIncrement />
        </NumberInputGroup>
      </NumberInput>
      <p
        id="order-quantity-message"
        className={
          invalid
            ? "m-0 text-xs text-destructive"
            : "m-0 text-xs text-muted-foreground"
        }
      >
        {invalid ? `Only ${stock} left in stock.` : `${stock} in stock.`}
      </p>
    </div>
  )
}

export { UiSdkNumberInputInvalid }
```

### Disabled

A disabled field can’t be focused or changed, and doesn’t submit.

```tsx title="components/examples/ui-sdk-number-input/disabled.tsx"
import { Label } from "@/components/ui/label"
import {
  NumberInput,
  NumberInputControl,
  NumberInputDecrement,
  NumberInputGroup,
  NumberInputIncrement,
} from "@/components/ui/ui-sdk-number-input"

function UiSdkNumberInputDisabled() {
  return (
    <div className="flex w-full max-w-40 flex-col gap-2">
      <Label htmlFor="sold-out">Quantity</Label>
      <NumberInput defaultValue={3} disabled>
        <NumberInputGroup>
          <NumberInputDecrement />
          <NumberInputControl id="sold-out" />
          <NumberInputIncrement />
        </NumberInputGroup>
      </NumberInput>
      <p className="m-0 text-xs text-muted-foreground">Sold out.</p>
    </div>
  )
}

export { UiSdkNumberInputDisabled }
```

### Read-only

A read-only field can be focused and submits, but steps, scrubs and typing don’t change it.

```tsx title="components/examples/ui-sdk-number-input/read-only.tsx"
import { Label } from "@/components/ui/label"
import {
  NumberInput,
  NumberInputControl,
  NumberInputDecrement,
  NumberInputGroup,
  NumberInputIncrement,
} from "@/components/ui/ui-sdk-number-input"

function UiSdkNumberInputReadOnly() {
  return (
    <div className="flex w-full max-w-40 flex-col gap-2">
      <Label htmlFor="included-seats">Seats included</Label>
      <NumberInput defaultValue={5} readOnly>
        <NumberInputGroup>
          <NumberInputDecrement />
          <NumberInputControl id="included-seats" />
          <NumberInputIncrement />
        </NumberInputGroup>
      </NumberInput>
      <p className="m-0 text-xs text-muted-foreground">Set by your plan.</p>
    </div>
  )
}

export { UiSdkNumberInputReadOnly }
```

### Right to left

The buttons swap sides, and the locale sets the digits and the currency.

```tsx title="components/examples/ui-sdk-number-input/rtl.tsx"
import { Label } from "@/components/ui/label"
import {
  NumberInput,
  NumberInputControl,
  NumberInputDecrement,
  NumberInputGroup,
  NumberInputIncrement,
} from "@/components/ui/ui-sdk-number-input"

function UiSdkNumberInputRtl() {
  return (
    <div dir="rtl" className="flex w-full max-w-44 flex-col gap-2">
      <Label htmlFor="rtl-amount">المبلغ</Label>
      <NumberInput
        defaultValue={250}
        min={0}
        step={5}
        locale="ar-EG"
        format={{ style: "currency", currency: "EGP" }}
      >
        <NumberInputGroup>
          <NumberInputDecrement aria-label="إنقاص" />
          <NumberInputControl id="rtl-amount" />
          <NumberInputIncrement aria-label="زيادة" />
        </NumberInputGroup>
      </NumberInput>
    </div>
  )
}

export { UiSdkNumberInputRtl }
```

## Keyboard

| Key | Action |
| --- | --- |
| `↑` `↓` | Steps the value by step. |
| `Shift` `PageUp` `PageDown` | Steps by largeStep, ten steps by default. Shift works with the arrows, the buttons, the wheel and a scrub. |
| `Alt` | With an arrow key, steps by smallStep, a tenth of a step by default. |
| `Home` `End` | Goes to the minimum or the maximum, when set. |
| `Enter` | Commits and formats what’s typed. |

## Accessibility

The field follows the keyboard of the WAI-ARIA spinbutton pattern on a native text input, so ordinary text editing keeps working. Name the input with a label or `aria-label`. The step buttons are named “Increase” and “Decrease” by default, point at the input, and stay out of the tab order because the arrow keys do the same.

A field with decimals asks for a decimal keypad on phones, and the step buttons grow on touch screens. They only shrink on press when motion is allowed.

## API reference

Each part wraps the UI SDK part of the same name and accepts all of its props, including `render` and a `className` that can be a function of the part’s state.

### NumberInput

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` | `number \| null` | – |  |
| `defaultValue` | `number \| null` | – |  |
| `onValueChange` | `(value: number \| null, details) => void` | – |  |
| `onValueCommit` | `(value: number \| null, details) => void` | – | Runs once an interaction ends. For saving. |
| `min` | `number` | – |  |
| `max` | `number` | – |  |
| `step` | `number` | `1` |  |
| `largeStep` | `number` | `step × 10` |  |
| `smallStep` | `number` | `step ÷ 10` |  |
| `format` | `Intl.NumberFormatOptions` | – |  |
| `locale` | `string` | – | Defaults to the page’s language. |
| `wheel` | `boolean` | `false` |  |
| `invalid` | `boolean` | `false` |  |
| `disabled` | `boolean` | `false` |  |
| `readOnly` | `boolean` | `false` |  |
| `required` | `boolean` | `false` |  |
| `name` | `string` | – |  |
| `form` | `string` | – |  |
| `controller` | `NumberInputController` | – | From createNumberInputController(). |

| Attribute | Description |
| --- | --- |
| `data-slot="number-input"` | Target the number input in CSS. |
| `data-scrubbing` | Present while a scrub changes the value. |
| `data-invalid` | Present when invalid. |
| `data-disabled` | Present when disabled. |
| `data-readonly` | Present when read-only. |
| `data-required` | Present when required. |

### NumberInputGroup

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `variant` | `"split" \| "stacked" \| "plain"` | `"split"` | Buttons on both sides, chevrons stacked at the end, or no buttons, for a scrub handle. |
| `render` | `ReactElement \| (props, state) => ReactElement` | – |  |

| Attribute | Description |
| --- | --- |
| `data-slot="number-input-group"` | Target the field’s box in CSS. |
| `data-variant` | The variant: split, stacked or plain. |

### NumberInputControl

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `id` | `string` | – |  |
| `placeholder` | `string` | – |  |
| `render` | `ReactElement \| (props, state) => ReactElement` | – |  |

### NumberInputIncrement

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `aria-label` | `string` | `"Increase"` |  |
| `disabled` | `boolean` | – | Turns off this button alone. |
| `children` | `ReactNode \| (state) => ReactNode` | – | Defaults to a plus, or a chevron in the stacked variant. |
| `render` | `ReactElement \| (props, state) => ReactElement` | – |  |

| Attribute | Description |
| --- | --- |
| `data-slot="number-input-increment"` | Target the button in CSS. |
| `data-at-bound` | Present when the value is at max. |
| `data-disabled` | Present when the button can’t step. |

### NumberInputDecrement

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `aria-label` | `string` | `"Decrease"` |  |
| `disabled` | `boolean` | – | Turns off this button alone. |
| `render` | `ReactElement \| (props, state) => ReactElement` | – |  |

| Attribute | Description |
| --- | --- |
| `data-slot="number-input-decrement"` | Target the button in CSS. |
| `data-at-bound` | Present when the value is at min. |

### NumberInputStepper

Stacks the increment above the decrement at the end of a stacked group.

### NumberInputScrubArea

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `orientation` | `"horizontal" \| "vertical"` | `"horizontal"` | Right and up increase the value. |
| `pixelsPerStep` | `number` | – | How far the pointer moves for one step. |
| `controller` | `NumberInputController` | – | Connects a scrub area outside the number input, like an icon in a toolbar. |

| Attribute | Description |
| --- | --- |
| `data-slot="number-input-scrub-area"` | Target the scrub area in CSS. |
| `data-orientation` | horizontal or vertical. |
| `data-scrubbing` | Present while a drag changes the value. |

### NumberInputScrubAreaCursor

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `children` | `ReactNode` | – | Defaults to a sideways arrow, centered on the pointer. |

## Notes for AI assistants

- Install a component with the shadcn CLI: `npx shadcn@latest add https://hextaui.com/r/<name>.json`. It adds the source, the HextaUI theme tokens and any HextaUI components it depends on. `https://hextaui.com/r/all.json` installs every component.
- The code is then owned by the project, like shadcn/ui. There is no HextaUI npm package. HextaUI is MIT licensed and free for personal and commercial use.
- Behavior and accessibility come from Base UI (`@base-ui/react`). Compose with the `render` prop, not `asChild`.
- Styling uses Tailwind CSS v4 with theme tokens. Merge classes with `cn` from the `cn` package.
- Icons come from `@tabler/icons-react`.
- Import components from `@/components/ui/<name>`, hooks from `@/hooks/<name>` and utilities from `@/lib/<name>`.

Every HextaUI doc: https://hextaui.com/llms.txt

## Related

- [Calendar](https://hextaui.com/docs/calendar) - A date grid for single, range and multiple selection, with sliding months, range previews and touch-sized days.
- [Checkbox](https://hextaui.com/docs/checkbox) - A checkbox whose check draws in, with indeterminate parents, groups and labels that share its hover.
- [Combobox](https://hextaui.com/docs/combobox) - A filterable select with chips, groups and async results, in a popup that resizes as you type.
- [Date picker](https://hextaui.com/docs/date-picker) - A button that opens a calendar in a popover, or a bottom sheet on phones, for single dates and ranges.
- [Field](https://hextaui.com/docs/field) - Labels, descriptions and errors wired to their control, with validation states and layouts for forms.
- [Input](https://hextaui.com/docs/input) - A text input with three sizes, invalid and read-only states, native validation styling and a 16px touch font so phones never zoom in.
