{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"stepper-input","type":"registry:component","title":"Stepper input","description":"A tactile incrementer: tap to step, hold to accelerate, bounds give an elastic bumper shake, and digits roll in place.","author":"Hairline UI","dependencies":[],"registryDependencies":["https://hairlineui.com/r/number-roll.json?as=dependency"],"files":[{"path":"registry/stepper-input.tsx","type":"registry:component","content":"// Hairline UI · stepper-input · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\nimport { NumberRoll } from \"./number-roll\";\n\n/* ─────────────────────────────────────────────────────────\n * STEPPER INPUT: a tactile number control with acceleration\n *\n *   single tap   increments or decrements by step, rolling\n *                the odometer in the direction of travel\n *   holding      waits 300ms, then accelerates smoothly from\n *                100ms down to 30ms intervals with scaled steps\n *   boundary     hitting min or max bounds disables the action\n *                and gives the container an elastic bumper shake\n *   direct edit  clicking the number reveals an exact input,\n *                committing on blur or Enter, cancelling on Escape\n * ───────────────────────────────────────────────────────── */\n\nexport interface StepperInputProps {\n  /** Accessible label, e.g. \"Team seats\" or \"Border radius\". */\n  label: string;\n  value?: number;\n  defaultValue?: number;\n  onValueChange?: (value: number) => void;\n  min?: number;\n  max?: number;\n  step?: number;\n  /** Large step for PageUp/PageDown and prolonged acceleration. Defaults to step * 10. */\n  largeStep?: number;\n  /** Optional unit appended to the number, e.g. \"px\", \"%\", \"seats\". */\n  unit?: string;\n  /** Format options passed to NumberRoll when not editing. */\n  format?: Intl.NumberFormatOptions;\n  /** Visual variant: \"pill\" for rounded-full, \"field\" for standard form input. */\n  variant?: \"pill\" | \"field\";\n  size?: \"sm\" | \"default\";\n  disabled?: boolean;\n  name?: string;\n  className?: string;\n}\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\n\nconst reducedQuery = \"(prefers-reduced-motion: reduce)\";\nconst subscribeReduced = (onChange: () => void) => {\n  const query = window.matchMedia(reducedQuery);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n};\nconst useReducedMotion = () =>\n  React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);\n\nfunction MinusIcon({ className = \"size-3.5\" }: { className?: string }) {\n  return (\n    <svg viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden=\"true\" className={className}>\n      <path d=\"M3.5 8h9\" stroke=\"currentColor\" strokeWidth=\"1.6\" strokeLinecap=\"round\" />\n    </svg>\n  );\n}\n\nfunction PlusIcon({ className = \"size-3.5\" }: { className?: string }) {\n  return (\n    <svg viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden=\"true\" className={className}>\n      <path d=\"M8 3.5v9M3.5 8h9\" stroke=\"currentColor\" strokeWidth=\"1.6\" strokeLinecap=\"round\" />\n    </svg>\n  );\n}\n\nconst shake = (el: HTMLElement | null, reduced: boolean) => {\n  if (!el || reduced) return;\n  el.animate(\n    [\n      { transform: \"none\" },\n      { transform: \"translateX(-3px)\" },\n      { transform: \"translateX(3px)\" },\n      { transform: \"translateX(-1.5px)\" },\n      { transform: \"none\" },\n    ],\n    { duration: 280, easing: \"ease-out\", composite: \"add\" }\n  );\n};\n\nexport function StepperInput({\n  label,\n  value: controlledValue,\n  defaultValue = 0,\n  onValueChange,\n  min = -Infinity,\n  max = Infinity,\n  step = 1,\n  largeStep,\n  unit,\n  format,\n  variant = \"pill\",\n  size = \"default\",\n  disabled = false,\n  name,\n  className = \"\",\n}: StepperInputProps) {\n  const reduced = useReducedMotion();\n  const [uncontrolledValue, setUncontrolledValue] = React.useState(defaultValue);\n  const isControlled = controlledValue !== undefined;\n  const currentVal = isControlled ? controlledValue : uncontrolledValue;\n\n  const [editing, setEditing] = React.useState(false);\n  const [editText, setEditText] = React.useState(String(currentVal));\n\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const inputRef = React.useRef<HTMLInputElement>(null);\n  const holdTimerRef = React.useRef<number | null>(null);\n  const holdIntervalRef = React.useRef<number | null>(null);\n  const holdStartRef = React.useRef<number>(0);\n\n  const effectiveLargeStep = largeStep ?? step * 10;\n  const atMin = currentVal <= min;\n  const atMax = currentVal >= max;\n\n  const clamp = React.useCallback(\n    (n: number) => {\n      let rounded = Math.round(n / step) * step;\n      // Fix floating point precision, e.g. 0.1 + 0.2\n      const stepDecimals = (step.toString().split(\".\")[1] || \"\").length;\n      if (stepDecimals > 0) {\n        rounded = Number(rounded.toFixed(stepDecimals));\n      }\n      return Math.min(max, Math.max(min, rounded));\n    },\n    [min, max, step]\n  );\n\n  const updateVal = React.useCallback(\n    (nextRaw: number) => {\n      const next = clamp(nextRaw);\n      if (next === currentVal) {\n        shake(rootRef.current, reduced);\n        return false;\n      }\n      if (!isControlled) {\n        setUncontrolledValue(next);\n      }\n      onValueChange?.(next);\n      return true;\n    },\n    [clamp, currentVal, isControlled, onValueChange, reduced]\n  );\n\n  const stopHold = React.useCallback(() => {\n    if (holdTimerRef.current !== null) {\n      window.clearTimeout(holdTimerRef.current);\n      holdTimerRef.current = null;\n    }\n    if (holdIntervalRef.current !== null) {\n      window.clearInterval(holdIntervalRef.current);\n      holdIntervalRef.current = null;\n    }\n  }, []);\n\n  const stepDelta = React.useCallback(\n    (dir: 1 | -1, multiplier = 1) => {\n      const delta = step * multiplier * dir;\n      return updateVal(currentVal + delta);\n    },\n    [currentVal, step, updateVal]\n  );\n\n  const startHold = React.useCallback(\n    (dir: 1 | -1) => {\n      if (disabled) return;\n      stopHold();\n\n      // Immediate first step\n      const canMove = stepDelta(dir, 1);\n      if (!canMove) return;\n\n      holdStartRef.current = Date.now();\n\n      // 300ms initial pause before rapid firing\n      holdTimerRef.current = window.setTimeout(() => {\n        const tick = () => {\n          const elapsed = Date.now() - holdStartRef.current;\n          let mult = 1;\n          let intervalTime = 90;\n\n          if (elapsed > 2400) {\n            mult = Math.max(1, Math.round(effectiveLargeStep / step));\n            intervalTime = 30;\n          } else if (elapsed > 1200) {\n            mult = Math.max(1, Math.round((effectiveLargeStep / step) / 2));\n            intervalTime = 55;\n          }\n\n          const ok = stepDelta(dir, mult);\n          if (!ok) {\n            stopHold();\n            return;\n          }\n\n          holdIntervalRef.current = window.setTimeout(tick, intervalTime);\n        };\n        tick();\n      }, 300);\n    },\n    [disabled, effectiveLargeStep, step, stepDelta, stopHold]\n  );\n\n  React.useEffect(() => {\n    return () => stopHold();\n  }, [stopHold]);\n\n  // Sync edit text when value changes from outside while not editing\n  React.useEffect(() => {\n    if (!editing) {\n      setEditText(String(currentVal));\n    }\n  }, [currentVal, editing]);\n\n  const commitEdit = React.useCallback(() => {\n    setEditing(false);\n    const parsed = parseFloat(editText);\n    if (!Number.isNaN(parsed)) {\n      updateVal(parsed);\n    } else {\n      setEditText(String(currentVal));\n    }\n  }, [currentVal, editText, updateVal]);\n\n  const handleKeyDown = (e: React.KeyboardEvent) => {\n    if (disabled) return;\n\n    if (e.key === \"ArrowUp\") {\n      e.preventDefault();\n      stepDelta(1, 1);\n    } else if (e.key === \"ArrowDown\") {\n      e.preventDefault();\n      stepDelta(-1, 1);\n    } else if (e.key === \"PageUp\") {\n      e.preventDefault();\n      updateVal(currentVal + effectiveLargeStep);\n    } else if (e.key === \"PageDown\") {\n      e.preventDefault();\n      updateVal(currentVal - effectiveLargeStep);\n    } else if (e.key === \"Home\" && min !== -Infinity) {\n      e.preventDefault();\n      updateVal(min);\n    } else if (e.key === \"End\" && max !== Infinity) {\n      e.preventDefault();\n      updateVal(max);\n    } else if (editing) {\n      if (e.key === \"Enter\") {\n        e.preventDefault();\n        commitEdit();\n      } else if (e.key === \"Escape\") {\n        e.preventDefault();\n        setEditing(false);\n        setEditText(String(currentVal));\n      }\n    }\n  };\n\n  const isSmall = size === \"sm\";\n  const hCls = isSmall ? \"h-7\" : \"h-9\";\n  const btnW = isSmall ? \"w-7\" : \"w-8\";\n  const roundedCls = variant === \"pill\" ? \"rounded-full\" : \"rounded-lg\";\n\n  return (\n    <div\n      ref={rootRef}\n      role=\"group\"\n      aria-label={label}\n      tabIndex={disabled ? -1 : 0}\n      onKeyDown={handleKeyDown}\n      className={`inline-flex ${hCls} items-center border border-border bg-background p-0.5 select-none ${roundedCls} transition-[box-shadow,border-color] duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 ${disabled ? \"opacity-50 pointer-events-none\" : \"\"} ${className}`}\n      style={{\n        boxShadow: \"0 0 0 1px transparent\",\n      }}\n    >\n      {/* Hidden input for HTML form submissions */}\n      {name && <input type=\"hidden\" name={name} value={currentVal} />}\n\n      {/* Decrement Button */}\n      <button\n        type=\"button\"\n        tabIndex={-1}\n        aria-label={`Decrease ${label}`}\n        disabled={disabled || atMin}\n        onPointerDown={(e) => {\n          if (e.pointerType === \"mouse\" && e.button !== 0) return;\n          try {\n            e.currentTarget.setPointerCapture(e.pointerId);\n          } catch {}\n          startHold(-1);\n        }}\n        onPointerUp={(e) => {\n          try {\n            if (e.currentTarget.hasPointerCapture(e.pointerId)) {\n              e.currentTarget.releasePointerCapture(e.pointerId);\n            }\n          } catch {}\n          stopHold();\n        }}\n        onPointerCancel={stopHold}\n        className={`flex ${btnW} h-full items-center justify-center ${variant === \"pill\" ? \"rounded-full\" : \"rounded-md\"} text-muted-foreground transition-colors hover:bg-muted hover:text-foreground active:scale-95 disabled:pointer-events-none disabled:opacity-30 cursor-pointer disabled:cursor-default touch-manipulation`}\n      >\n        <MinusIcon />\n      </button>\n\n      {/* Value Display / Direct Edit */}\n      <div\n        className=\"relative flex min-w-[3.25rem] flex-1 items-center justify-center px-2 font-mono text-[13px] tabular-nums text-foreground cursor-text\"\n        onClick={() => {\n          if (disabled || editing) return;\n          setEditing(true);\n          setEditText(String(currentVal));\n          setTimeout(() => inputRef.current?.select(), 10);\n        }}\n      >\n        {editing ? (\n          <input\n            ref={inputRef}\n            type=\"text\"\n            inputMode=\"numeric\"\n            value={editText}\n            onChange={(e) => setEditText(e.target.value)}\n            onBlur={commitEdit}\n            className=\"w-full bg-transparent text-center font-mono text-[13px] text-foreground outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none\"\n            autoFocus\n          />\n        ) : (\n          <div className=\"flex items-baseline gap-1\" aria-hidden=\"true\">\n            <NumberRoll value={currentVal} format={format} duration={260} />\n            {unit && <span className=\"font-sans text-[11px] text-muted-foreground\">{unit}</span>}\n          </div>\n        )}\n\n        {/* Screen reader live value */}\n        <span className=\"sr-only\" role=\"status\" aria-live=\"polite\">\n          {label}: {currentVal} {unit ?? \"\"}\n        </span>\n      </div>\n\n      {/* Increment Button */}\n      <button\n        type=\"button\"\n        tabIndex={-1}\n        aria-label={`Increase ${label}`}\n        disabled={disabled || atMax}\n        onPointerDown={(e) => {\n          if (e.pointerType === \"mouse\" && e.button !== 0) return;\n          try {\n            e.currentTarget.setPointerCapture(e.pointerId);\n          } catch {}\n          startHold(1);\n        }}\n        onPointerUp={(e) => {\n          try {\n            if (e.currentTarget.hasPointerCapture(e.pointerId)) {\n              e.currentTarget.releasePointerCapture(e.pointerId);\n            }\n          } catch {}\n          stopHold();\n        }}\n        onPointerCancel={stopHold}\n        className={`flex ${btnW} h-full items-center justify-center ${variant === \"pill\" ? \"rounded-full\" : \"rounded-md\"} text-muted-foreground transition-colors hover:bg-muted hover:text-foreground active:scale-95 disabled:pointer-events-none disabled:opacity-30 cursor-pointer disabled:cursor-default touch-manipulation`}\n      >\n        <PlusIcon />\n      </button>\n    </div>\n  );\n}\n"}],"css":{},"docs":"Docs and live preview: https://hairlineui.com/ui/stepper-input. MIT licensed: https://hairlineui.com/license#components","meta":{"license":"MIT"}}