HextaUI

BetaBuilt on Vercel’s UI SDK 0.1.0-beta.2. Its API may change.

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.

Depends on
  • @tabler/icons-react
  • @ui-sdk/core
  • @ui-sdk/react
  • class-variance-authority
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 }
pnpm dlx shadcn@latest add https://hextaui.com/r/ui-sdk-number-input.json

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

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.

@layer ui-sdk;

@import "tailwindcss";
import {
  NumberInput,
  NumberInputControl,
  NumberInputDecrement,
  NumberInputGroup,
  NumberInputIncrement,
} from "@/components/ui/ui-sdk-number-input"
<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.

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

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

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 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.

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 }

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.

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 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.

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 }

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.

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 }

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.

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 }

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.

"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 }

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.

"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 marks the field and sets aria-invalid on the input, for checks of your own like stock.

"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 }

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

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 }

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

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 }

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

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 }
KeyAction
↑↓Steps the value by step.
ShiftPageUpPageDownSteps by largeStep, ten steps by default. Shift works with the arrows, the buttons, the wheel and a scrub.
AltWith an arrow key, steps by smallStep, a tenth of a step by default.
HomeEndGoes to the minimum or the maximum, when set.
EnterCommits and formats what’s typed.

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.

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.

PropTypeDefault
value
number | null–
defaultValue
number | null–
onValueChange
(value: number | null, details) => void–
onValueCommitRuns once an interaction ends. For saving.
(value: number | null, details) => void–
min
number–
max
number–
step
number1
largeStep
numberstep × 10
smallStep
numberstep ÷ 10
format
Intl.NumberFormatOptions–
localeDefaults to the page’s language.
string–
wheel
booleanfalse
invalid
booleanfalse
disabled
booleanfalse
readOnly
booleanfalse
required
booleanfalse
name
string–
form
string–
controllerFrom createNumberInputController().
NumberInputController–
AttributeDescription
data-slot="number-input"Target the number input in CSS.
data-scrubbingPresent while a scrub changes the value.
data-invalidPresent when invalid.
data-disabledPresent when disabled.
data-readonlyPresent when read-only.
data-requiredPresent when required.
PropTypeDefault
variantButtons on both sides, chevrons stacked at the end, or no buttons, for a scrub handle.
"split" | "stacked" | "plain""split"
render
ReactElement | (props, state) => ReactElement–
AttributeDescription
data-slot="number-input-group"Target the field’s box in CSS.
data-variantThe variant: split, stacked or plain.
PropTypeDefault
id
string–
placeholder
string–
render
ReactElement | (props, state) => ReactElement–
PropTypeDefault
aria-label
string"Increase"
disabledTurns off this button alone.
boolean–
childrenDefaults to a plus, or a chevron in the stacked variant.
ReactNode | (state) => ReactNode–
render
ReactElement | (props, state) => ReactElement–
AttributeDescription
data-slot="number-input-increment"Target the button in CSS.
data-at-boundPresent when the value is at max.
data-disabledPresent when the button can’t step.
PropTypeDefault
aria-label
string"Decrease"
disabledTurns off this button alone.
boolean–
render
ReactElement | (props, state) => ReactElement–
AttributeDescription
data-slot="number-input-decrement"Target the button in CSS.
data-at-boundPresent when the value is at min.

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

PropTypeDefault
orientationRight and up increase the value.
"horizontal" | "vertical""horizontal"
pixelsPerStepHow far the pointer moves for one step.
number–
controllerConnects a scrub area outside the number input, like an icon in a toolbar.
NumberInputController–
AttributeDescription
data-slot="number-input-scrub-area"Target the scrub area in CSS.
data-orientationhorizontal or vertical.
data-scrubbingPresent while a drag changes the value.
PropTypeDefault
childrenDefaults to a sideways arrow, centered on the pointer.
ReactNode–