Skip to contentVibraUI
Inputs & filters

Number input

A number field with quiet chevron steppers, arrow-key stepping, and bounds enforced on blur.

Controlled when value is set, uncontrolled otherwise; an empty field is null, not zero. The text is the source of truth while it is being typed, so a half-written decimal survives the trip through a number, and min and max are only applied on blur — a value can be retyped from the middle without the field fighting back. Steppers round to the decimals of the step, so 0.1 plus 0.2 is 0.3. Every prop the input takes passes through to the input itself; className sizes the field around it. parseNumeric is exported for reading typed amounts elsewhere. aria-valuetext reads out what the field shows, affixes included, so a currency field announces $1,234.50 rather than the bare number behind it. The steppers are deliberately small and out of the tab order — they are the field's trim, not its control — so ArrowUp and ArrowDown, and the field itself, are the targets that matter. At a bound only the stepper that has run out is disabled and dimmed; the field dims as a whole only when it is disabled itself. It stands on the card plane in both themes, as Input does, and so do CurrencyInput and SliderInput's field, which are built on it.

Install

npx shadcn@latest add @vibra/number-input

Needs the @vibra registry in your components.json — set it up once.

Examples

Props

PropTypeDefaultDescription
data-slotstring"number-input"The slot name the field's root reports; components built on NumberInput set their own.
valuenumber | null—The current number; setting it makes the field controlled.
defaultValuenumber | null—The starting number for an uncontrolled field.
onValueChange(value: number | null) => void—Called with the new number, or null once the field is empty.
minnumber—Lower bound, applied on blur and to the steppers.
maxnumber—Upper bound, applied on blur and to the steppers.
stepnumber1How much a stepper or an arrow key moves the value.
precisionnumber—Decimal places every committed value is rounded to; left out, typed decimals are kept as typed.
prefixReact.ReactNode—Sits inside the field before the number, e.g. a currency symbol.
suffixReact.ReactNode—Sits inside the field after the number, e.g. a unit.
size"sm" | "default""default"sm drops the field to h-7 for dense forms.
format(value: number) => stringgrouped digitsHow the number reads while the field is not focused; typing always shows raw digits.
hideControlsbooleanfalseDrops the steppers; the arrow keys still work.

Dependencies

Source

components/ui/number-input.tsx
"use client"

import * as React from "react"
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react"

import { cn } from "@/lib/utils"
import { clamp, formatNumber } from "@/lib/format"
import { Input } from "@/components/ui/input"

/** Reads a number out of typed text, keeping only digits, a minus, and a decimal point; returns null when what is left is not a number ("", "-", "1.2.3"). */
export function parseNumeric(text: string): number | null {
  const cleaned = text.replace(/[^0-9.-]/g, "")
  if (!cleaned) return null
  const parsed = Number(cleaned)
  return Number.isFinite(parsed) ? parsed : null
}

/** How many decimal places a number is written with, e.g. 0.25 → 2. */
function decimalsOf(value: number): number {
  const text = String(value)
  const point = text.indexOf(".")
  return point === -1 ? 0 : text.length - point - 1
}

function roundTo(value: number, decimals: number): number {
  const factor = 10 ** decimals
  return Math.round(value * factor) / factor
}

// "prefix" joins the omitted names because <input>'s own prefix attribute is an
// RDFa string, and this one is a node rendered inside the field.
export type NumberInputProps = Omit<
  React.ComponentProps<"input">,
  "value" | "onChange" | "size" | "min" | "max" | "step" | "prefix"
> & {
  /** The data-slot the field's root reports; components built on NumberInput set their own. */
  "data-slot"?: string
  value?: number | null
  defaultValue?: number | null
  onValueChange?: (value: number | null) => void
  min?: number
  max?: number
  step?: number
  /** Decimal places every committed value is rounded to; left out, typed decimals are kept as typed. */
  precision?: number
  /** Sits inside the field before the number, e.g. a currency symbol. */
  prefix?: React.ReactNode
  /** Sits inside the field after the number, e.g. a unit. */
  suffix?: React.ReactNode
  size?: "sm" | "default"
  /** How the number reads while the field is not focused; typing always shows the raw digits. */
  format?: (value: number) => string
  hideControls?: boolean
}

/** A number field with quiet chevron steppers, arrow-key stepping, and bounds enforced on blur. */
function NumberInput({
  className,
  "data-slot": slot = "number-input",
  value,
  defaultValue,
  onValueChange,
  min,
  max,
  step = 1,
  precision,
  prefix,
  suffix,
  size = "default",
  format,
  hideControls = false,
  disabled,
  onBlur,
  onFocus,
  onKeyDown,
  ...props
}: NumberInputProps) {
  const isControlled = value !== undefined
  const [uncontrolled, setUncontrolled] = React.useState<number | null>(defaultValue ?? null)
  const current = isControlled ? (value ?? null) : uncontrolled

  // How the number reads at rest: grouped by default, or however `format` says.
  const display = (n: number | null) =>
    n === null ? "" : format ? format(n) : formatNumber(n, { maximumFractionDigits: 20 })
  // How it reads under the caret: raw digits, so a caret can be put anywhere.
  const raw = (n: number | null) => (n === null ? "" : String(n))

  const [focused, setFocused] = React.useState(false)
  const [text, setText] = React.useState(() => raw(current))
  const [lastValue, setLastValue] = React.useState(current)

  // The text is the source of truth while it is being typed — "1." must survive
  // the round trip through 1 — so it is only rewritten when the incoming number
  // is not the one the text already spells.
  if (current !== lastValue) {
    setLastValue(current)
    if (parseNumeric(text) !== current) setText(raw(current))
  }

  const bound = (n: number) => clamp(n, min ?? -Infinity, max ?? Infinity)
  const round = (n: number) => (precision === undefined ? n : roundTo(n, precision))

  function commit(next: number | null) {
    setText(raw(next))
    if (!isControlled) setUncontrolled(next)
    if (next !== current) onValueChange?.(next)
  }

  function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
    const nextText = event.target.value
    setText(nextText)
    const parsed = parseNumeric(nextText)
    // Bounds are only enforced on blur, so a number can be retyped from the
    // middle without the field fighting back.
    const next = parsed === null ? null : round(parsed)
    if (!isControlled) setUncontrolled(next)
    if (next !== current) onValueChange?.(next)
  }

  function handleBlur(event: React.FocusEvent<HTMLInputElement>) {
    setFocused(false)
    const parsed = parseNumeric(event.target.value)
    commit(parsed === null ? null : bound(round(parsed)))
    onBlur?.(event)
  }

  function stepBy(direction: 1 | -1) {
    const base = current ?? 0
    const decimals = precision ?? Math.max(decimalsOf(step), decimalsOf(base))
    commit(bound(roundTo(base + step * direction, decimals)))
  }

  function handleKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
    if (event.key === "ArrowUp") {
      event.preventDefault()
      stepBy(1)
    } else if (event.key === "ArrowDown") {
      event.preventDefault()
      stepBy(-1)
    }
    onKeyDown?.(event)
  }

  // What the field shows, affixes included, so a screen reader reads out
  // "$1,234.50" rather than the bare 1234.5 behind it.
  const valueText =
    current === null
      ? undefined
      : `${typeof prefix === "string" ? prefix : ""}${display(current)}${
          typeof suffix === "string" ? ` ${suffix}` : ""
        }`

  const atMax = max !== undefined && current !== null && current >= max
  const atMin = min !== undefined && current !== null && current <= min

  return (
    <div
      data-slot={slot}
      data-size={size}
      data-disabled={disabled || undefined}
      // On the card plane in both themes, as Input is. The field dims when its
      // input is disabled — never because a stepper has run out at a bound,
      // which dims that stepper alone.
      className={cn(
        "flex w-full min-w-0 items-center rounded-lg border border-input bg-card transition-colors has-[input:disabled]:opacity-50 has-[input:focus-visible]:focus-outline",
        size === "sm" ? "h-7" : "h-8",
        className
      )}
    >
      {prefix ? (
        <span
          data-slot="number-input-prefix"
          aria-hidden="true"
          className="ps-2.5 text-sm text-muted-foreground select-none"
        >
          {prefix}
        </span>
      ) : null}

      <Input
        type="text"
        inputMode="decimal"
        autoComplete="off"
        role="spinbutton"
        aria-valuenow={current ?? undefined}
        aria-valuetext={valueText}
        aria-valuemin={min}
        aria-valuemax={max}
        disabled={disabled}
        value={focused ? text : display(current)}
        onChange={handleChange}
        onFocus={(event) => {
          setFocused(true)
          setText(raw(current))
          onFocus?.(event)
        }}
        onBlur={handleBlur}
        onKeyDown={handleKeyDown}
        className={cn(
          "h-full flex-1 rounded-none border-0 bg-transparent px-2.5 tabular-nums shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
          // An affix already holds the gutter on its side, so the number sits
          // beside it rather than a full field's padding away.
          prefix ? "ps-1.5" : undefined,
          suffix ? "pe-1.5" : undefined
        )}
        {...props}
      />

      {suffix ? (
        <span
          data-slot="number-input-suffix"
          aria-hidden="true"
          className="pe-2.5 text-sm text-muted-foreground select-none"
        >
          {suffix}
        </span>
      ) : null}

      {hideControls ? null : (
        <div
          data-slot="number-input-controls"
          className="flex h-full shrink-0 flex-col justify-center border-s border-input"
        >
          <StepperButton label="Increment" disabled={disabled || atMax} onClick={() => stepBy(1)}>
            <ChevronUpIcon className="size-3" />
          </StepperButton>
          <StepperButton label="Decrement" disabled={disabled || atMin} onClick={() => stepBy(-1)}>
            <ChevronDownIcon className="size-3" />
          </StepperButton>
        </div>
      )}
    </div>
  )
}

// Half-height so the pair fits the field exactly; too small for any Button size,
// and quiet on purpose — the field is the control, these are its trim.
function StepperButton({
  label,
  ...props
}: React.ComponentProps<"button"> & { label: string }) {
  return (
    <button
      type="button"
      tabIndex={-1}
      aria-label={label}
      data-slot="number-input-stepper"
      // Keeps the caret in the field, so a run of clicks never bounces focus.
      onMouseDown={(event) => event.preventDefault()}
      className="flex h-1/2 w-5 items-center justify-center text-muted-foreground transition-colors hover:text-foreground focus-ring disabled:pointer-events-none disabled:opacity-40"
      {...props}
    />
  )
}

export { NumberInput }