Inline number stepper with decrement/increment buttons replacing native number input
Install
npx shadcn add https://ui.happenings.social/r/number-stepper.json
Or with namespace
npx shadcn add @happenings/number-stepper
Preview
Dependencies
src/components/ui/number-stepper.tsx
"use client"
import * as React from "react"
import { faMinus, faPlus } from "@fortawesome/pro-solid-svg-icons"
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"
import { cn } from "@/lib/utils"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./tooltip"
interface NumberStepperProps {
value: number
onChange: (value: number) => void
min?: number
max?: number
step?: number
disabled?: boolean
name?: string
id?: string
className?: string
decreaseLabel?: string
increaseLabel?: string
}
function NumberStepper({
value,
onChange,
min = 0,
max,
step = 1,
disabled,
name,
id,
className,
decreaseLabel = "Decrease",
increaseLabel = "Increase",
}: NumberStepperProps) {
const atMin = value <= min
const atMax = max !== undefined && value >= max
function decrement() {
const next = value - step
onChange(Math.max(min, next))
}
function increment() {
const next = value + step
onChange(max !== undefined ? Math.min(max, next) : next)
}
function handleInputChange(e: React.ChangeEvent<HTMLInputElement>) {
const raw = e.target.value.replace(/\D/g, "")
if (raw === "") {
onChange(min)
return
}
let n = parseInt(raw, 10)
if (n < min) n = min
if (max !== undefined && n > max) n = max
onChange(n)
}
return (
<TooltipProvider delayDuration={300}>
<div
data-slot="number-stepper"
className={cn(
"inline-flex items-center",
disabled && "pointer-events-none opacity-50",
className,
)}
>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={decrement}
disabled={disabled || atMin}
aria-label={decreaseLabel}
className="inline-flex h-9 w-9 shrink-0 cursor-pointer items-center justify-center rounded-l-md border border-r-0 border-input bg-muted/50 text-sm transition-colors hover:bg-accent disabled:pointer-events-none disabled:opacity-40 dark:bg-input/30 dark:hover:bg-input/50"
>
<FontAwesomeIcon icon={faMinus} className="size-3" />
</button>
</TooltipTrigger>
<TooltipContent>{decreaseLabel}</TooltipContent>
</Tooltip>
<input
type="text"
inputMode="numeric"
id={id}
name={name}
value={value}
onChange={handleInputChange}
disabled={disabled}
className="h-9 w-12 min-w-0 border border-input bg-transparent text-center text-sm tabular-nums outline-none transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 dark:bg-input/30"
/>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={increment}
disabled={disabled || atMax}
aria-label={increaseLabel}
className="inline-flex h-9 w-9 shrink-0 cursor-pointer items-center justify-center rounded-r-md border border-l-0 border-input bg-muted/50 text-sm transition-colors hover:bg-accent disabled:pointer-events-none disabled:opacity-40 dark:bg-input/30 dark:hover:bg-input/50"
>
<FontAwesomeIcon icon={faPlus} className="size-3" />
</button>
</TooltipTrigger>
<TooltipContent>{increaseLabel}</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
)
}
export { NumberStepper }
export type { NumberStepperProps }
Preview