Number Field
A wrapper for Base UI NumberField with increment/decrement buttons, min/max constraints, step control, Intl number formatting, and wheel/keyboard scrubbing.
Basic
Default — increment/decrement buttons, keyboard arrows
Min / max — constrained to 1–10
Step — increments by 0.5
Format
Currency — Intl.NumberFormat style: currency
Percent — Intl.NumberFormat style: percent
Large step — Shift+Arrow uses largeStep (10)
Interaction
Wheel scrub — scroll while focused to change value
State
Error — invalid + error message
Quantity is required
Disabled — non-interactive
Read only — display only
Installation
npx shadcn@latest add @glrk-ui/number-fieldIf you haven't set up the prerequisites yet, check out Prerequest section.
Copy and paste the following code into your project.
'use client'
import { MinusIcon, PlusIcon } from 'lucide-react'
import { NumberField as NumberFieldPrimitive } from '@base-ui/react/number-field'
import { cn } from '@/lib/utils'
const NumberFieldRoot = NumberFieldPrimitive.Root
function NumberFieldGroup({ className, ...props }: React.ComponentProps<typeof NumberFieldPrimitive.Group>) {
return (
<NumberFieldPrimitive.Group
data-slot="number-field-group"
className={cn(
'flex h-9 w-full items-center rounded-md border border-input bg-transparent text-sm shadow-xs transition-colors',
'focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50',
'has-aria-invalid:border-destructive has-aria-invalid:ring-3 has-aria-invalid:ring-destructive/20',
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
'dark:bg-input/30 dark:has-aria-invalid:border-destructive/50 dark:has-aria-invalid:ring-destructive/40',
className,
)}
{...props}
/>
)
}
function NumberFieldInput({ className, ...props }: React.ComponentProps<typeof NumberFieldPrimitive.Input>) {
return (
<NumberFieldPrimitive.Input
data-slot="number-field-input"
className={cn(
'h-full min-w-0 flex-1 bg-transparent px-2.5 text-center outline-none',
'placeholder:text-muted-foreground',
'disabled:cursor-not-allowed',
className,
)}
{...props}
/>
)
}
function NumberFieldDecrement({ className, ...props }: React.ComponentProps<typeof NumberFieldPrimitive.Decrement>) {
return (
<NumberFieldPrimitive.Decrement
data-slot="number-field-decrement"
className={cn(
'flex h-full w-8 shrink-0 items-center justify-center rounded-l-[calc(var(--radius)-2px)] text-muted-foreground',
'transition-colors hover:bg-accent hover:text-foreground',
'disabled:pointer-events-none disabled:opacity-50',
'[&_svg]:pointer-events-none [&_svg]:size-3.5',
className,
)}
{...props}
>
<MinusIcon />
</NumberFieldPrimitive.Decrement>
)
}
function NumberFieldIncrement({ className, ...props }: React.ComponentProps<typeof NumberFieldPrimitive.Increment>) {
return (
<NumberFieldPrimitive.Increment
data-slot="number-field-increment"
className={cn(
'flex h-full w-8 shrink-0 items-center justify-center rounded-r-[calc(var(--radius)-2px)] text-muted-foreground',
'transition-colors hover:bg-accent hover:text-foreground',
'disabled:pointer-events-none disabled:opacity-50',
'[&_svg]:pointer-events-none [&_svg]:size-3.5',
className,
)}
{...props}
>
<PlusIcon />
</NumberFieldPrimitive.Increment>
)
}
function NumberFieldScrubArea({ className, ...props }: React.ComponentProps<typeof NumberFieldPrimitive.ScrubArea>) {
return (
<NumberFieldPrimitive.ScrubArea
data-slot="number-field-scrub-area"
className={cn('cursor-ew-resize select-none', className)}
{...props}
/>
)
}
function NumberFieldScrubAreaCursor({ className, ...props }: React.ComponentProps<typeof NumberFieldPrimitive.ScrubAreaCursor>) {
return (
<NumberFieldPrimitive.ScrubAreaCursor
data-slot="number-field-scrub-area-cursor"
className={cn('flex items-center gap-1.5 text-foreground', className)}
{...props}
/>
)
}
type NumberFieldWrapperProps = React.ComponentProps<typeof NumberFieldPrimitive.Root> & {
className?: string
placeholder?: string
}
function NumberFieldWrapper({ className, placeholder, ...props }: NumberFieldWrapperProps) {
return (
<NumberFieldRoot {...props}>
<NumberFieldGroup className={className}>
<NumberFieldDecrement />
<NumberFieldInput placeholder={placeholder} />
<NumberFieldIncrement />
</NumberFieldGroup>
</NumberFieldRoot>
)
}
export {
NumberFieldRoot,
NumberFieldGroup,
NumberFieldInput,
NumberFieldDecrement,
NumberFieldIncrement,
NumberFieldScrubArea,
NumberFieldScrubAreaCursor,
NumberFieldWrapper,
}
Usage
Standalone (NumberFieldWrapper)
import { NumberFieldWrapper } from "@/components/ui/number-field"
const [value, setValue] = useState<number | null>(0)
<NumberFieldWrapper
value={value}
onValueChange={setValue}
min={0}
max={100}
step={1}
/>With field label and error (NumberWrapper)
NumberWrapper wraps NumberFieldWrapper inside a Field with label and error display. Import from field-wrapper:
import { NumberWrapper } from "@/components/ui/field-wrapper"
<NumberWrapper
name="quantity"
label="Quantity"
value={value}
onValueChange={setValue}
min={0}
step={1}
/>Constraints
<NumberWrapper name="rating" label="Rating" min={1} max={10} step={1} />Step increments
// Step 0.5
<NumberWrapper name="amount" label="Amount" step={0.5} />
// Shift+Arrow uses largeStep (10), Meta+Arrow uses smallStep (0.1)
<NumberWrapper name="year" label="Year" step={1} largeStep={10} smallStep={1} />Intl number formatting
// Currency
<NumberWrapper
name="price"
label="Price"
format={{ style: "currency", currency: "USD" }}
min={0}
/>
// Percent
<NumberWrapper
name="discount"
label="Discount"
format={{ style: "percent" }}
min={0}
max={1}
step={0.01}
/>Wheel scrubbing
<NumberWrapper
name="volume"
label="Volume"
min={0}
max={100}
allowWheelScrub
/>With validation error
<NumberWrapper
name="qty"
label="Quantity"
value={null}
invalid
error={{ message: "Quantity is required" }}
/>React Hook Form
import { NumberWrapper } from "@/components/ui/field-wrapper-rhf"
<NumberWrapper name="quantity" label="Quantity" control={form.control} min={0} step={1} />TanStack Form
<form.AppField
name="quantity"
validators={{
onChange: ({ value }) =>
value !== null && value < 0 ? "Must be 0 or more" : undefined,
}}
>
{field => <field.NumberField label="Quantity" min={0} step={1} />}
</form.AppField>Using primitives
import {
NumberFieldRoot,
NumberFieldGroup,
NumberFieldInput,
NumberFieldDecrement,
NumberFieldIncrement,
NumberFieldScrubArea,
NumberFieldScrubAreaCursor,
} from "@/components/ui/number-field"
<NumberFieldRoot min={0} max={100} step={1}>
<NumberFieldScrubArea>
<NumberFieldScrubAreaCursor />
</NumberFieldScrubArea>
<NumberFieldGroup>
<NumberFieldDecrement />
<NumberFieldInput />
<NumberFieldIncrement />
</NumberFieldGroup>
</NumberFieldRoot>Reference
NumberFieldWrapper
Prop
Type