Adjusts a small number with buttons or arrow keys. Digits roll with the change and nudge at the limits.
$36/month
1 to 10 seats
pnpm dlx shadcn@latest add @latheui/number-stepperFirst time? Run npx latheui init once to add the @latheui registry. The CLI then writes the source into your project and installs what it needs. Or add it by hand:
Install the npm dependencies.
pnpm add lucide-react motionAdd the Lathe items this component imports.
lib/motion.tshooks/use-controllable-state.tsCopy the source into your project.
"use client"
import * as React from "react"
import { MinusIcon, PlusIcon } from "lucide-react"
import { AnimatePresence, motion, useAnimationControls } from "motion/react"
import { cn } from "@/lib/utils"
import { useControllableState } from "@/hooks/use-controllable-state"
import { spring, useMotionSafe } from "@/lib/motion"
export type NumberStepperProps = Omit<
React.ComponentProps<"div">,
"onChange" | "defaultValue"
> & {
value?: number
defaultValue?: number
onValueChange?: (value: number) => void
min?: number
max?: number
step?: number
/** Formats the displayed value, for example to add a unit. */
format?: (value: number) => string
disabled?: boolean
/** Accessible name for the value, for example "Quantity". */
label: string
/** Submits the value with a form. */
name?: string
}
const clamp = (value: number, min: number, max: number) =>
Math.min(max, Math.max(min, value))
function NumberStepper({
value: valueProp,
defaultValue = 0,
onValueChange,
min = -Infinity,
max = Infinity,
step = 1,
format = String,
disabled = false,
label,
name,
className,
...props
}: NumberStepperProps) {
const [value, setValue] = useControllableState({
value: valueProp,
defaultValue: clamp(defaultValue, min, max),
onChange: onValueChange,
})
const [direction, setDirection] = React.useState(1)
const controls = useAnimationControls()
const { reduced, safe } = useMotionSafe()
const latest = React.useRef(value)
React.useEffect(() => {
latest.current = value
})
const repeat = React.useRef<{
timeout?: ReturnType<typeof setTimeout>
interval?: ReturnType<typeof setInterval>
}>({})
const stopRepeat = React.useCallback(() => {
clearTimeout(repeat.current.timeout)
clearInterval(repeat.current.interval)
}, [])
React.useEffect(() => stopRepeat, [stopRepeat])
function change(delta: number) {
const next = clamp(latest.current + delta, min, max)
if (next === latest.current) {
// At a limit: nudge toward the blocked side and settle back.
stopRepeat()
if (!reduced)
controls.start({
x: [0, delta > 0 ? 5 : -5, 0],
transition: { duration: 0.28, ease: "easeOut" },
})
return
}
latest.current = next
setDirection(delta > 0 ? 1 : -1)
setValue(next)
}
function startRepeat(delta: number) {
change(delta)
stopRepeat()
repeat.current.timeout = setTimeout(() => {
repeat.current.interval = setInterval(() => change(delta), 70)
}, 420)
}
function onKeyDown(event: React.KeyboardEvent) {
if (disabled) return
const big = step * 10
const actions: Record<string, () => void> = {
ArrowUp: () => change(step),
ArrowRight: () => change(step),
ArrowDown: () => change(-step),
ArrowLeft: () => change(-step),
PageUp: () => change(big),
PageDown: () => change(-big),
Home: () => Number.isFinite(min) && change(min - latest.current),
End: () => Number.isFinite(max) && change(max - latest.current),
}
const action = actions[event.key]
if (!action) return
event.preventDefault()
action()
}
const button = (delta: number, text: string, Icon: typeof PlusIcon) => (
<button
type="button"
// The spinbutton handles the keyboard, so these stay out of the tab order.
tabIndex={-1}
aria-label={text}
disabled={disabled}
// At a limit the button stays pressable so it can give the limit response.
aria-disabled={(delta > 0 ? value >= max : value <= min) || undefined}
onPointerDown={(event) => {
if (event.button === 0) startRepeat(delta)
}}
onPointerUp={stopRepeat}
onPointerLeave={stopRepeat}
onPointerCancel={stopRepeat}
className="grid size-8 shrink-0 cursor-pointer touch-manipulation place-items-center rounded-[5px] text-muted-foreground transition-[color,background-color,transform] duration-150 select-none hover:bg-accent hover:text-foreground active:scale-90 disabled:pointer-events-none aria-disabled:opacity-35 aria-disabled:hover:bg-transparent aria-disabled:active:scale-100 motion-reduce:active:scale-100"
>
<Icon aria-hidden="true" className="size-4" />
</button>
)
return (
<div
data-slot="number-stepper"
data-disabled={disabled || undefined}
className={cn(
"inline-flex h-9 items-center rounded-md border border-input bg-background p-px data-disabled:opacity-50",
className
)}
{...props}
>
{button(-step, "Decrease", MinusIcon)}
<motion.div
role="spinbutton"
tabIndex={disabled ? -1 : 0}
aria-label={label}
aria-valuenow={value}
aria-valuetext={format(value)}
aria-valuemin={Number.isFinite(min) ? min : undefined}
aria-valuemax={Number.isFinite(max) ? max : undefined}
aria-disabled={disabled || undefined}
onKeyDown={onKeyDown}
animate={controls}
className="relative flex h-full min-w-10 items-center justify-center overflow-hidden rounded-sm px-1.5 text-sm font-medium tabular-nums outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<AnimatePresence initial={false} mode="popLayout" custom={direction}>
<motion.span
key={value}
custom={direction}
variants={{
enter: (d: number) => ({ y: reduced ? 0 : d * 14, opacity: 0 }),
center: { y: 0, opacity: 1 },
exit: (d: number) => ({ y: reduced ? 0 : d * -14, opacity: 0 }),
}}
initial="enter"
animate="center"
exit="exit"
transition={safe(spring.snappy)}
>
{format(value)}
</motion.span>
</AnimatePresence>
</motion.div>
{button(step, "Increase", PlusIcon)}
{name && <input type="hidden" name={name} value={value} />}
</div>
)
}
export { NumberStepper }Depends on
Also accepts native div attributes.
| Prop | Type | Default | Description |
|---|---|---|---|
| label* (required) | string | — | Accessible name for the value. |
| value | number | — | Controlled value. |
| defaultValue | number | 0 | Initial value when uncontrolled, clamped to the limits. |
| onValueChange | (value: number) => void | — | Called with the new value. |
| min | number | -Infinity | Lowest allowed value. |
| max | number | Infinity | Highest allowed value. |
| step | number | 1 | Amount added or removed per press. |
| format | (value: number) => string | String | Formats the displayed value. Also used for the spoken value. |
| disabled | boolean | false | Prevents interaction. |
| name | string | — | Adds a hidden input so the value submits with a form. |
The value area grows with its content. Set a minimum width to stop the buttons moving when the digit count changes.
<NumberStepper label="Guests" className="[&_[role=spinbutton]]:min-w-16" max={500} />