# Number Stepper

> Quantity stepper with press-and-hold acceleration, rolling digits and arrow keys.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/number-stepper
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/number-stepper.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

```bash
npm install lucide-react
```

## Usage

```tsx title="components/number-stepper-demo.tsx"
import { NumberStepper } from "@/components/number-stepper"

export default function NumberStepperDemo() {
  return (
    <div className="flex w-full max-w-xs flex-col gap-4 rounded-2xl border bg-card p-5">
      <div className="flex items-center justify-between">
        <div>
          <p className="text-sm font-medium">Seats</p>
          <p className="text-xs text-muted-foreground">$12 per seat / month</p>
        </div>
        <NumberStepper defaultValue={5} min={1} max={50} label="Seats" />
      </div>
      <div className="flex items-center justify-between">
        <div>
          <p className="text-sm font-medium">Storage</p>
          <p className="text-xs text-muted-foreground">Hold to change faster</p>
        </div>
        <NumberStepper defaultValue={2.5} min={0.5} max={20} step={0.5} unit="TB" size="sm" label="Storage" />
      </div>
    </div>
  )
}
```

## Source

```tsx title="components/number-stepper.tsx"
"use client"

import * as React from "react"
import { Minus, Plus } from "lucide-react"

import { cn } from "@/lib/utils"

export interface NumberStepperProps extends Omit<React.ComponentProps<"div">, "onChange" | "defaultValue"> {
  value?: number
  defaultValue?: number
  onChange?: (value: number) => void
  min?: number
  max?: number
  step?: number
  label?: string
  /** Shown after the number, e.g. "kg" */
  unit?: string
  size?: "sm" | "md" | "lg"
}

const sizes = {
  sm: { box: "h-8", button: "w-8", text: "w-10 text-sm" },
  md: { box: "h-10", button: "w-10", text: "w-14 text-base" },
  lg: { box: "h-12", button: "w-12", text: "w-16 text-lg" },
}

// Holding a button repeats the step, speeding up the longer it is held.
export function NumberStepper({
  value,
  defaultValue = 0,
  onChange,
  min = 0,
  max = 99,
  step = 1,
  label = "Quantity",
  unit,
  size = "md",
  className,
  ...props
}: NumberStepperProps) {
  const [internal, setInternal] = React.useState(defaultValue)
  const current = value ?? internal
  // The value the hold-to-repeat timer builds on, ahead of React state
  const latest = React.useRef(current)
  React.useEffect(() => {
    latest.current = current
  }, [current])
  const [direction, setDirection] = React.useState<1 | -1>(1)
  const [draft, setDraft] = React.useState<string | null>(null)
  const repeat = React.useRef<ReturnType<typeof setTimeout>>(undefined)
  const s = sizes[size]

  const decimals = (String(step).split(".")[1] ?? "").length
  const clamp = (n: number) => Math.min(max, Math.max(min, Number(n.toFixed(decimals))))

  const set = (next: number) => {
    next = clamp(next)
    if (next === latest.current) return
    setDirection(next > latest.current ? 1 : -1)
    latest.current = next
    setInternal(next)
    onChange?.(next)
  }

  const stop = () => clearTimeout(repeat.current)
  const start = (delta: number) => {
    set(latest.current + delta)
    let delay = 400
    const loop = () => {
      repeat.current = setTimeout(() => {
        // A button disabled at the limit no longer gets pointerup
        if (clamp(latest.current + delta) === latest.current) return
        set(latest.current + delta)
        delay = Math.max(40, delay * 0.8)
        loop()
      }, delay)
    }
    loop()
  }
  React.useEffect(() => stop, [])

  const button = (delta: number, disabled: boolean, Icon: typeof Plus, name: string) => (
    <button
      type="button"
      aria-label={name}
      disabled={disabled}
      onPointerDown={(event) => {
        if (event.button !== 0) return
        event.preventDefault()
        start(delta)
      }}
      onPointerUp={stop}
      onPointerLeave={stop}
      onPointerCancel={stop}
      onKeyDown={(event) => {
        if (event.key === "Enter" || event.key === " ") {
          event.preventDefault()
          set(latest.current + delta)
        }
      }}
      className={cn(
        "flex h-full items-center justify-center text-muted-foreground transition-colors outline-none hover:bg-accent hover:text-foreground focus-visible:bg-accent disabled:pointer-events-none disabled:opacity-40",
        s.button
      )}
    >
      <Icon className="size-4" />
    </button>
  )

  return (
    <div
      className={cn("inline-flex items-center divide-x overflow-hidden rounded-lg border bg-background shadow-xs", s.box, className)}
      {...props}
    >
      {button(-step, current <= min, Minus, "Decrease")}
      <label className="relative flex h-full items-center justify-center overflow-hidden">
        <span className="sr-only">{label}</span>
        <input
          type="text"
          inputMode="decimal"
          role="spinbutton"
          aria-valuemin={min}
          aria-valuemax={max}
          aria-valuenow={current}
          value={draft ?? String(current)}
          onChange={(event) => setDraft(event.target.value)}
          onFocus={(event) => event.target.select()}
          onBlur={() => {
            if (draft !== null && draft.trim() !== "" && !Number.isNaN(Number(draft))) set(Number(draft))
            setDraft(null)
          }}
          onKeyDown={(event) => {
            if (event.key === "ArrowUp") set(current + step)
            else if (event.key === "ArrowDown") set(current - step)
            else if (event.key === "Enter") event.currentTarget.blur()
            else return
            event.preventDefault()
          }}
          className={cn("bg-transparent text-center font-medium tabular-nums outline-none", s.text, draft === null && "text-transparent")}
        />
        {draft === null && (
          <span
            key={current}
            aria-hidden
            className={cn(
              "pointer-events-none absolute inset-0 flex items-center justify-center gap-1 font-medium tabular-nums animate-in fade-in duration-200",
              direction > 0 ? "slide-in-from-bottom-3" : "slide-in-from-top-3",
              s.text.split(" ")[1]
            )}
          >
            {current}
            {unit && <span className="text-xs text-muted-foreground">{unit}</span>}
          </span>
        )}
      </label>
      {button(step, current >= max, Plus, "Increase")}
    </div>
  )
}
```
