A minus/plus number stepper whose digits roll up or down like an odometer on every change, clamps to min/max/step, accelerates while a button is held, and supports arrow, Page and Home/End keys.
Preview
Customize
11 propsInstallation
Copies the component source and its CSS Module into your project.
npx motionfoundry add number-stepperWrites components/ui/number-stepper.tsx and components/styles/number-stepper.module.css.
Usage
Updates live as you change the controls — only props that differ from the defaults are included.
1import { NumberStepper } from "@motionfoundry/ui";2 3export function Example() {4 return (5 <NumberStepper />6 );7}Props
Accessibility
Implements the WAI-ARIA spinbutton pattern: the value display is a focusable role="spinbutton" labelled by the visible label, with aria-valuenow/min/max and aria-valuetext including the suffix. Up/Right and Down/Left step, PageUp/PageDown take larger steps and Home/End jump to min/max. The minus/plus buttons are labelled ('Decrease Guests' / 'Increase Guests'), kept out of the tab order as in the APG example, and disable at the bounds. The rolling digits are aria-hidden. Under prefers-reduced-motion values change instantly.
Toggle prefers-reduced-motion in your OS or browser devtools (Rendering → Emulate CSS media feature) to verify the reduced-motion state.
Source
1"use client";2 3import * as React from "react";4import { AnimatePresence, motion, useReducedMotion } from "motion/react";5import styles from "../styles/number-stepper.module.css";6 7export interface NumberStepperProps {8 /** Controlled value */9 value?: number;10 /** Initial value when uncontrolled */11 defaultValue?: number;12 /** Called with the next (clamped) value */13 onValueChange?: (value: number) => void;14 /** Minimum value */15 min?: number;16 /** Maximum value */17 max?: number;18 /** Increment per step (decimals set the display precision) */19 step?: number;20 /** Visible label (also the accessible name) */21 label?: string;22 /** Text appended after the number, e.g. " kg" */23 suffix?: string;24 /** Visual style */25 variant?: "outline" | "soft";26 /** Size preset */27 size?: "sm" | "md" | "lg";28 /** Accent color of the buttons and focus ring */29 accentColor?: string;30 /** Keep stepping while a button is held down */31 holdToRepeat?: boolean;32 /** Disable the control */33 disabled?: boolean;34 /** Optional className merged onto the root */35 className?: string;36 /** Optional inline styles merged onto the root */37 style?: React.CSSProperties;38}39 40function decimalsOf(n: number): number {41 const s = String(n);42 const i = s.indexOf(".");43 return i === -1 ? 0 : Math.min(6, s.length - i - 1);44}45 46export function NumberStepper({47 value: valueProp,48 defaultValue = 2,49 onValueChange,50 min = 0,51 max = 12,52 step = 1,53 label = "Guests",54 suffix = "",55 variant = "outline",56 size = "md",57 accentColor = "#6366f1",58 holdToRepeat = true,59 disabled = false,60 className = "",61 style,62}: NumberStepperProps) {63 const reduce = useReducedMotion();64 const lo = Math.min(min, max);65 const hi = Math.max(min, max);66 const inc = step > 0 ? step : 1;67 const precision = decimalsOf(inc);68 const clamp = React.useCallback((n: number) => Math.min(hi, Math.max(lo, Number(n.toFixed(precision)))), [hi, lo, precision]);69 const [inner, setInner] = React.useState(() => clamp(defaultValue));70 const value = valueProp !== undefined ? clamp(valueProp) : inner;71 const [dir, setDir] = React.useState(1);72 const valueRef = React.useRef(value);73 valueRef.current = value;74 const timer = React.useRef<number | undefined>(undefined);75 const labelId = React.useId();76 77 const set = React.useCallback(78 (next: number) => {79 const v = clamp(next);80 if (v === valueRef.current) return false;81 setDir(v > valueRef.current ? 1 : -1);82 valueRef.current = v;83 if (valueProp === undefined) setInner(v);84 onValueChange?.(v);85 return true;86 },87 [clamp, onValueChange, valueProp],88 );89 90 const stopRepeat = React.useCallback(() => {91 window.clearTimeout(timer.current);92 timer.current = undefined;93 }, []);94 95 React.useEffect(() => stopRepeat, [stopRepeat]);96 97 const startRepeat = (sign: 1 | -1) => {98 if (disabled) return;99 set(valueRef.current + sign * inc);100 if (!holdToRepeat) return;101 let delay = 420;102 const tick = () => {103 if (!set(valueRef.current + sign * inc)) return stopRepeat();104 delay = Math.max(40, delay * 0.82);105 timer.current = window.setTimeout(tick, delay);106 };107 timer.current = window.setTimeout(tick, delay);108 };109 110 const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {111 if (disabled) return;112 const big = inc * Math.max(1, Math.round((hi - lo) / inc / 10));113 const map: Record<string, number | undefined> = {114 ArrowUp: value + inc,115 ArrowRight: value + inc,116 ArrowDown: value - inc,117 ArrowLeft: value - inc,118 PageUp: value + big,119 PageDown: value - big,120 Home: lo,121 End: hi,122 };123 const next = map[e.key];124 if (next === undefined) return;125 e.preventDefault();126 set(next);127 };128 129 const text = value.toFixed(precision);130 const chars = text.split("");131 const bindHold = (sign: 1 | -1) => ({132 onPointerDown: (e: React.PointerEvent<HTMLButtonElement>) => {133 if (e.button !== 0) return;134 e.preventDefault();135 startRepeat(sign);136 },137 onPointerUp: stopRepeat,138 onPointerLeave: stopRepeat,139 onPointerCancel: stopRepeat,140 onClick: (e: React.MouseEvent<HTMLButtonElement>) => {141 // Keyboard / assistive-tech activation (detail 0) — pointer presses are handled on pointerdown.142 if (e.detail === 0) set(valueRef.current + sign * inc);143 },144 });145 146 const roll = reduce ? { duration: 0 } : { type: "spring" as const, stiffness: 420, damping: 34 };147 148 return (149 <div150 className={`${styles.root} ${className}`.trim()}151 data-variant={variant}152 data-size={size}153 data-disabled={disabled ? "" : undefined}154 style={{ "--mf-ns-accent": accentColor, ...style } as React.CSSProperties}155 >156 <span id={labelId} className={styles.label}>157 {label}158 </span>159 <div className={styles.control}>160 <button type="button" tabIndex={-1} aria-label={`Decrease ${label}`} className={styles.btn} disabled={disabled || value <= lo} {...bindHold(-1)}>161 <svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round">162 <path d="M5 12h14" />163 </svg>164 </button>165 <div166 role="spinbutton"167 tabIndex={disabled ? -1 : 0}168 aria-labelledby={labelId}169 aria-valuenow={value}170 aria-valuemin={lo}171 aria-valuemax={hi}172 aria-valuetext={`${text}${suffix}`}173 aria-disabled={disabled || undefined}174 className={styles.display}175 onKeyDown={onKeyDown}176 >177 <span className={styles.digits} aria-hidden="true">178 {chars.map((ch, i) => {179 const place = chars.length - i;180 return (181 <span key={place} className={styles.cell}>182 <AnimatePresence initial={false} custom={dir} mode="popLayout">183 <motion.span184 key={ch}185 custom={dir}186 className={styles.digit}187 variants={{188 enter: (d: number) => ({ y: reduce ? 0 : `${d * 70}%`, opacity: 0 }),189 center: { y: "0%", opacity: 1 },190 exit: (d: number) => ({ y: reduce ? 0 : `${d * -70}%`, opacity: 0 }),191 }}192 initial="enter"193 animate="center"194 exit="exit"195 transition={roll}196 >197 {ch}198 </motion.span>199 </AnimatePresence>200 </span>201 );202 })}203 {suffix && <span className={styles.suffix}>{suffix}</span>}204 </span>205 </div>206 <button type="button" tabIndex={-1} aria-label={`Increase ${label}`} className={styles.btn} disabled={disabled || value >= hi} {...bindHold(1)}>207 <svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round">208 <path d="M12 5v14M5 12h14" />209 </svg>210 </button>211 </div>212 </div>213 );214}