Number Input
A stepper split into minus, value, and plus by hairline seams, rolling the value in the direction it changed with press-and-hold auto-repeat.
A compact stepper in our depth aesthetic: a raised, top-lit panel divided into minus, value, and plus by hairline seams. The buttons lift into a chip on hover and press down on click. Change the value and the digits roll in the direction they moved, up enters from below, down from above, so the change reads instantly. Built with Motion.
Click a button, or focus the value and use the arrow keys (Home and End jump to the bounds). Press and hold a button to auto-repeat, it accelerates the longer you hold.
Installation
Complete the shared Setup first, then copy the component into
components/ui/number-input.tsx.
bun add motion'use client';import * as React from 'react';import { AnimatePresence, motion, useReducedMotion } from 'motion/react';import { Minus, Plus } from 'lucide-react';import { cn } from '@/lib/cn';const EASE_OUT = [0.23, 1, 0.32, 1] as const;const HOLD_DELAY = 320;const HOLD_START = 140;const HOLD_MIN = 36;const HOLD_RAMP = 0.82;const FOCUS = 'focus-visible:outline-none focus-visible:outline focus-visible:outline-2 focus-visible:[outline-color:var(--ni-accent)] focus-visible:[outline-offset:-2px]';export type NumberInputProps = { value?: number; defaultValue?: number; onChange?: (value: number) => void; min?: number; max?: number; step?: number; accent?: string; disabled?: boolean; format?: (value: number) => string; 'aria-label'?: string; className?: string;};const clamp = (n: number, min: number, max: number) => Math.min(max, Math.max(min, n));function snap(n: number, step: number, min: number) { const base = Number.isFinite(min) ? min : 0; const snapped = Math.round((n - base) / step) * step + base; const decimals = (String(step).split('.')[1] ?? '').length; return decimals ? Number(snapped.toFixed(decimals)) : snapped;}export function NumberInput({ value: controlled, defaultValue = 0, onChange, min = -Infinity, max = Infinity, step = 1, accent = '#3ecf8e', disabled = false, format, 'aria-label': ariaLabel = 'Quantity', className,}: NumberInputProps) { const reduce = useReducedMotion(); const isControlled = controlled !== undefined; const [uncontrolled, setUncontrolled] = React.useState(() => clamp(defaultValue, min, max), ); const value = isControlled ? (controlled as number) : uncontrolled; const [dir, setDir] = React.useState(0); const atMin = value <= min; const atMax = value >= max; const commit = React.useCallback( (next: number) => { const clamped = clamp(snap(next, step, min), min, max); if (clamped === value) return; setDir(Math.sign(clamped - value)); if (!isControlled) setUncontrolled(clamped); onChange?.(clamped); }, [value, step, min, max, isControlled, onChange], ); const commitRef = React.useRef(commit); React.useEffect(() => { commitRef.current = commit; }, [commit]); const step1 = (sign: 1 | -1) => commitRef.current(value + sign * step); const holdTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null); const stopHold = React.useCallback(() => { if (holdTimer.current) { clearTimeout(holdTimer.current); holdTimer.current = null; } }, []); const startHold = (sign: 1 | -1) => { step1(sign); let interval = HOLD_START; const tick = () => { commitRef.current(valueRef.current + sign * step); if ( (sign > 0 && valueRef.current >= max) || (sign < 0 && valueRef.current <= min) ) { stopHold(); return; } interval = Math.max(HOLD_MIN, interval * HOLD_RAMP); holdTimer.current = setTimeout(tick, interval); }; holdTimer.current = setTimeout(tick, HOLD_DELAY); }; const valueRef = React.useRef(value); React.useEffect(() => { valueRef.current = value; }, [value]); React.useEffect(() => stopHold, [stopHold]); const onKeyDown = (e: React.KeyboardEvent) => { if (disabled) return; if (e.key === 'ArrowUp') { e.preventDefault(); step1(1); } else if (e.key === 'ArrowDown') { e.preventDefault(); step1(-1); } else if (e.key === 'Home' && Number.isFinite(min)) { e.preventDefault(); commit(min); } else if (e.key === 'End' && Number.isFinite(max)) { e.preventDefault(); commit(max); } }; const display = format ? format(value) : String(value); return ( <div style={{ '--ni-accent': accent } as React.CSSProperties} className={cn( 'bloom-edge inline-flex h-11 select-none items-stretch overflow-hidden rounded-[14px]', 'bg-gradient-to-b from-white to-[#f4f4f5] text-neutral-900', 'dark:from-[#1d1d1d] dark:to-[#161716] dark:text-white', 'shadow-[0_1px_2px_0_rgba(0,0,0,0.05),0_5px_14px_-10px_rgba(0,0,0,0.1)]', 'dark:shadow-[inset_0_1px_0_0_rgba(255,255,255,0.05),0_1px_2px_0_rgba(0,0,0,0.4),0_6px_16px_-10px_rgba(0,0,0,0.45)]', disabled && 'opacity-50', className, )} > <StepButton kind="minus" disabled={disabled || atMin} reduce={!!reduce} onPress={() => startHold(-1)} onRelease={stopHold} /> <Divider /> <div role="spinbutton" tabIndex={disabled ? -1 : 0} aria-label={ariaLabel} aria-valuenow={value} aria-valuemin={Number.isFinite(min) ? min : undefined} aria-valuemax={Number.isFinite(max) ? max : undefined} aria-valuetext={format ? display : undefined} aria-disabled={disabled || undefined} onKeyDown={onKeyDown} className={cn( 'relative grid min-w-[3.5rem] place-items-center px-3 rounded-[6px]', FOCUS, )} > <div className="relative grid place-items-center overflow-hidden"> <span aria-hidden className="invisible px-0.5 text-[17px] font-semibold tabular-nums tracking-[-0.01em]" > {display} </span> <AnimatePresence mode="popLayout" initial={false} custom={dir}> <motion.span key={display} aria-hidden custom={dir} initial={ reduce ? { opacity: 0 } : { opacity: 0, y: dir >= 0 ? '0.6em' : '-0.6em', filter: 'blur(4px)' } } animate={{ opacity: 1, y: 0, filter: 'blur(0px)' }} exit={ reduce ? { opacity: 0, transition: { duration: 0.1 } } : { opacity: 0, y: dir >= 0 ? '-0.6em' : '0.6em', filter: 'blur(4px)', transition: { duration: 0.12, ease: EASE_OUT }, } } transition={{ duration: 0.22, ease: EASE_OUT }} className="absolute px-0.5 text-[17px] font-semibold tabular-nums tracking-[-0.01em]" > {display} </motion.span> </AnimatePresence> </div> </div> <Divider /> <StepButton kind="plus" disabled={disabled || atMax} reduce={!!reduce} onPress={() => startHold(1)} onRelease={stopHold} /> </div> );}function Divider() { return ( <span aria-hidden className="my-2 w-px bg-gradient-to-b from-black/[0.04] via-black/[0.09] to-black/[0.04] dark:from-white/[0.03] dark:via-white/[0.1] dark:to-white/[0.03]" /> );}function StepButton({ kind, disabled, reduce, onPress, onRelease,}: { kind: 'minus' | 'plus'; disabled: boolean; reduce: boolean; onPress: () => void; onRelease: () => void;}) { const Icon = kind === 'plus' ? Plus : Minus; const begin = (e: React.PointerEvent) => { if (disabled) return; e.preventDefault(); (e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId); onPress(); }; const end = (e: React.PointerEvent) => { try { (e.currentTarget as HTMLElement).releasePointerCapture?.(e.pointerId); } catch {} onRelease(); }; return ( <motion.button type="button" tabIndex={-1} disabled={disabled} aria-label={kind === 'plus' ? 'Increment' : 'Decrement'} onPointerDown={begin} onPointerUp={end} onPointerLeave={end} onPointerCancel={end} whileTap={reduce || disabled ? undefined : { scale: 0.92 }} className={cn( 'group relative grid w-11 shrink-0 place-items-center', 'text-neutral-500 dark:text-[#929292]', 'transition-colors duration-150', !disabled && 'cursor-pointer hover:text-neutral-900 dark:hover:text-white', disabled && 'cursor-not-allowed opacity-40', )} > {!disabled && ( <span aria-hidden className={cn( 'pointer-events-none absolute inset-1 rounded-[9px] opacity-0', 'transition-opacity duration-150', 'bg-gradient-to-b from-white to-[#f0f0f1] dark:from-[#2a2a2a] dark:to-[#242424]', 'shadow-[0_0_0_1px_rgba(0,0,0,0.05),inset_0_1px_0_0_rgba(255,255,255,0.9),0_2px_5px_-1px_rgba(0,0,0,0.12)]', 'dark:shadow-[0_0_0_1px_rgba(255,255,255,0.06),inset_0_1px_0_0_rgba(255,255,255,0.06),0_3px_8px_-2px_rgba(0,0,0,0.6)]', 'group-hover:opacity-100 group-active:opacity-100', )} /> )} <Icon className="relative size-4" strokeWidth={2.25} aria-hidden /> </motion.button> );}Usage
import { NumberInput } from '@/components/ui/number-input';
export default function Example() {
return <NumberInput defaultValue={3} min={0} max={99} />;
}Examples
Give it a step and an accent. Holding a button flies through the range.
Pass value and onChange to control it, and format to render units or
currency without changing the underlying number.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
value | number | - | Controlled value. Pair with onChange. |
defaultValue | number | 0 | Uncontrolled starting value. |
onChange | (value: number) => void | - | Fired with the next clamped, step-snapped value. |
min | number | -Infinity | Smallest allowed value. |
max | number | Infinity | Largest allowed value. |
step | number | 1 | Increment per step. |
accent | string | "#3ecf8e" | Focus outline and held-button tint. |
disabled | boolean | false | Disables the whole control. |
format | (value: number) => string | - | Formats the displayed number (currency, units). |
aria-label | string | "Quantity" | Accessible label for the spinbutton. |
className | string | - | Forwarded to the panel. |