A bounded numeric field with native spinbutton semantics, step buttons, and controlled or uncontrolled state.
Invite seats
Choose how many teammates can join.
import { NumberInput } from "@/components/ballmac/number-input"
export default function NumberInputDemo() {
return (
<div className="w-full max-w-xs rounded-xl border bg-card p-5 shadow-sm">
<p className="text-sm font-semibold">Invite seats</p>
<p className="mt-1 mb-4 text-xs text-muted-foreground">
Choose how many teammates can join.
</p>
<NumberInput label="Invite seats" defaultValue={3} min={1} max={20} />
</div>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/number-inputInstall the dependencies.
$ pnpm add lucide-reactAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/i18nCopy the source into your project.
components/ballmac/number-input.tsx// Ballmac UI: Number Input. https://ui.ballmac.com/components/number-input "use client" import * as React from "react" import { Minus, Plus } from "lucide-react" import { cn } from "@/lib/utils" import { useMessages } from "@/lib/ballmac/i18n" type NumberInputProps = Omit< React.ComponentProps<"div">, "onChange" | "defaultValue" > & { /** Controlled numeric value. Null renders an empty field. */ value?: number | null /** Initial value when uncontrolled. */ defaultValue?: number | null /** Called when the number changes. */ onValueChange?: (value: number | null) => void /** Lowest allowed value. */ min?: number /** Highest allowed value. */ max?: number /** Increment used by buttons and arrow keys. */ step?: number /** Accessible name for the input. */ label?: string /** Native form field name. */ name?: string /** Disable input and buttons. */ disabled?: boolean } function NumberInput({ value, defaultValue = 0, onValueChange, min, max, step = 1, label, name, disabled = false, className, ...props }: NumberInputProps) { const msg = useMessages() label ??= msg("number-input.label", "Number") const [internal, setInternal] = React.useState<number | null>(defaultValue) const current = value !== undefined ? value : internal const safeStep = Number.isFinite(step) && step > 0 ? step : 1 function clamp(next: number) { return Math.min( max ?? Infinity, Math.max(min ?? -Infinity, Math.round(next * 1e8) / 1e8), ) } function commit(next: number | null) { if (value === undefined) setInternal(next) onValueChange?.(next) } function change(delta: number) { commit(clamp((current ?? min ?? 0) + delta * safeStep)) } return ( <div data-slot="number-input" className={cn( "flex h-9 w-full min-w-0 items-center rounded-md border border-input bg-background shadow-xs transition-[border-color,box-shadow] duration-150 motion-reduce:transition-none focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50", className, )} {...props} > <button type="button" aria-label={msg("number-input.decrease", "Decrease {label}", { label })} disabled={ disabled || (current != null && min != null && current <= min) } onClick={() => change(-1)} className="flex size-9 shrink-0 items-center justify-center rounded-s-md outline-none hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:opacity-50" > <Minus aria-hidden="true" className="size-4" /> </button> <input data-slot="number-input-field" type="number" inputMode="decimal" aria-label={label} name={name} min={min} max={max} step={safeStep} disabled={disabled} value={current ?? ""} onChange={(event) => { const next = event.currentTarget.value commit(next === "" ? null : Number(next)) }} onBlur={() => { if (current != null) commit(clamp(current)) }} className="h-full min-w-0 flex-1 appearance-none border-x border-input bg-transparent px-2 text-center text-sm tabular-nums text-foreground outline-none disabled:opacity-50 [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" /> <button type="button" aria-label={msg("number-input.increase", "Increase {label}", { label })} disabled={ disabled || (current != null && max != null && current >= max) } onClick={() => change(1)} className="flex size-9 shrink-0 items-center justify-center rounded-e-md outline-none hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:opacity-50" > <Plus aria-hidden="true" className="size-4" /> </button> </div> ) } export { NumberInput, type NumberInputProps }Update the import paths to match your project setup.
Usage
import { NumberInput } from "@/components/ballmac/number-input"The full example is in the Code tab above.
Examples
States and variants
Hours per week
import { NumberInput } from "@/components/ballmac/number-input"
export default function NumberInputStates() {
return (
<div className="w-full max-w-xs">
<p className="mb-2 text-sm font-medium">Hours per week</p>
<NumberInput
label="Hours per week"
defaultValue={2.5}
min={0}
max={40}
step={0.5}
/>
</div>
)
}API reference
| Prop | Type | Default |
|---|---|---|
valueControlled numeric value. Null renders an empty field. | number | null | — |
defaultValueInitial value when uncontrolled. | number | null | 0 |
onValueChangeCalled when the number changes. | (value: number | null) => void | — |
minLowest allowed value. | number | — |
maxHighest allowed value. | number | — |
stepIncrement used by buttons and arrow keys. | number | 1 |
labelAccessible name for the input. | string | — |
nameNative form field name. | string | — |
disabledDisable input and buttons. | boolean | false |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Arrow Up / Down | Changes the native numeric value |
| Tab / Enter | Operates the step buttons |
Use with AI
A bounded numeric field with native spinbutton semantics, step buttons, and controlled or uncontrolled state. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Number Input (@ballmac/number-input) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Choose a quantity or bounded count
- Adjust a value precisely using keys or buttons
Not for
- Use a slider when approximate adjustment is sufficient
Registry JSON: https://ui.ballmac.com/r/number-input.json
Credits
Free to use in personal and commercial projects.
- npm
- lucide-react
- Registry
- @ballmac/i18nshadcn/utils
Pairs well with
Input
A text input in three heights that match Button, plus InputGroup and InputGroupAddon for leading or trailing icons, units and domains.
Calendar
A date grid for one day, several days or a range, with month and year selects, week numbers, disabled rules and range-end styling, on React DayPicker.
Color Picker
A native color chooser paired with an editable hex field and a live swatch.
Combobox
A searchable single-choice select with groups, descriptions, keywords, clearable value, invalid state and hidden-input form submission.