UIForms and Inputsmotion/react

NumberStepper

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

Guests

Customize

11 props
2
0
12
1

Installation

Copies the component source and its CSS Module into your project.

npx motionfoundry add number-stepper

Writes 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.

example.tsx
1import { NumberStepper } from "@motionfoundry/ui";2 3export function Example() {4  return (5    <NumberStepper />6  );7}

Props

PropTypeDefault
value

Controlled value.

numberundefined
defaultValue

Initial value when uncontrolled (clamped to min/max).

number2
onValueChange

Called with the next clamped value.

(value: number) => voidundefined
min

Minimum value.

number0
max

Maximum value.

number12
step

Increment per step; its decimal places set the display precision.

number1
label

Visible label and accessible name.

string'Guests'
suffix

Text shown after the number and included in aria-valuetext, e.g. ' kg'.

string''
variant

Visual style.

'outline' | 'soft''outline'
size

Size preset.

'sm' | 'md' | 'lg''md'
accentColor

Button hover/soft tint and focus ring color.

string'#6366f1'
holdToRepeat

Keep stepping (and accelerating) while a button is held.

booleantrue
disabled

Disable the control.

booleanfalse
className

Additional class merged onto the root element.

string''
style

Inline styles merged onto the root element.

React.CSSPropertiesundefined

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

components/ui/number-stepper.tsx
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}