Glrk UI

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
Disabled — non-interactive
Read only — display only

Installation

npx shadcn@latest add @glrk-ui/number-field

If you haven't set up the prerequisites yet, check out Prerequest section.

Copy and paste the following code into your project.

ui/number-field.tsx
'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