{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "number-stepper",
  "title": "Number Stepper",
  "description": "Minus / plus stepper whose digits roll individually, with hold-to-repeat and a shake at the limits.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/new-york/number-stepper/number-stepper.tsx",
      "content": "\"use client\"\n\nimport { useEffect, useRef, useState } from \"react\"\nimport { AnimatePresence, motion, useAnimate, useReducedMotion } from \"motion/react\"\nimport { cn } from \"@/lib/utils\"\n\nexport interface NumberStepperProps {\n  /** Controlled value. */\n  value?: number\n  /** Initial value when uncontrolled. Default: min */\n  defaultValue?: number\n  /** Called with the new value on every change. */\n  onValueChange?: (value: number) => void\n  /** Lowest allowed value. Default: 0 */\n  min?: number\n  /** Highest allowed value. Default: 99 */\n  max?: number\n  /** Amount added or removed per step. Default: 1 */\n  step?: number\n  /** Text shown before the value, e.g. \"$\". */\n  prefix?: string\n  /** Text shown after the value, e.g. \"kg\". */\n  suffix?: string\n  /** Control size. Default: \"md\" */\n  size?: \"sm\" | \"md\" | \"lg\"\n  /** Accessible label for the value. Default: \"Quantity\" */\n  \"aria-label\"?: string\n  className?: string\n}\n\nconst sizes = {\n  sm: { root: \"h-9 gap-1 p-1 text-sm\", button: \"size-7\", value: \"min-w-10\" },\n  md: { root: \"h-11 gap-1.5 p-1 text-base\", button: \"size-9\", value: \"min-w-12\" },\n  lg: { root: \"h-14 gap-2 p-1.5 text-xl\", button: \"size-11\", value: \"min-w-16\" },\n}\n\nconst decimals = (n: number) => (String(n).split(\".\")[1] ?? \"\").length\n\nconst Minus = () => (\n  <svg viewBox=\"0 0 16 16\" className=\"size-4\" fill=\"none\" stroke=\"currentColor\" strokeWidth={2} strokeLinecap=\"round\">\n    <path d=\"M3.5 8h9\" />\n  </svg>\n)\n\nconst Plus = () => (\n  <svg viewBox=\"0 0 16 16\" className=\"size-4\" fill=\"none\" stroke=\"currentColor\" strokeWidth={2} strokeLinecap=\"round\">\n    <path d=\"M3.5 8h9M8 3.5v9\" />\n  </svg>\n)\n\nexport function NumberStepper({\n  value,\n  defaultValue,\n  onValueChange,\n  min = 0,\n  max = 99,\n  step = 1,\n  prefix,\n  suffix,\n  size = \"md\",\n  \"aria-label\": ariaLabel = \"Quantity\",\n  className,\n}: NumberStepperProps) {\n  const reduced = useReducedMotion()\n  const [internal, setInternal] = useState(defaultValue ?? min)\n  const current = value ?? internal\n  const [prev, setPrev] = useState(current)\n  const [dir, setDir] = useState(1)\n  if (current !== prev) {\n    setPrev(current)\n    setDir(current > prev ? 1 : -1)\n  }\n\n  const [scope, animate] = useAnimate<HTMLDivElement>()\n  const valueRef = useRef(current)\n  const holdTimer = useRef<ReturnType<typeof setTimeout>>(undefined)\n\n  useEffect(() => {\n    valueRef.current = current\n  }, [current])\n\n  useEffect(() => () => clearTimeout(holdTimer.current), [])\n\n  const places = Math.max(decimals(step), decimals(min))\n\n  const shake = () => {\n    if (reduced || !scope.current) return\n    animate(scope.current, { x: [0, -5, 5, -3, 3, 0] }, { duration: 0.35 })\n  }\n\n  const change = (delta: number) => {\n    const raw = valueRef.current + delta * step\n    const next = Number(Math.min(max, Math.max(min, raw)).toFixed(places))\n    if (next === valueRef.current) {\n      shake()\n      return false\n    }\n    valueRef.current = next\n    if (value === undefined) setInternal(next)\n    onValueChange?.(next)\n    return true\n  }\n\n  const stopHold = () => clearTimeout(holdTimer.current)\n\n  const startHold = (e: React.PointerEvent, delta: number) => {\n    if (e.button !== 0) return\n    stopHold()\n    if (!change(delta)) return\n    const repeat = (delay: number) => {\n      holdTimer.current = setTimeout(() => {\n        if (change(delta)) repeat(Math.max(40, delay * 0.85))\n      }, delay)\n    }\n    repeat(400)\n  }\n\n  const onKeyDown = (e: React.KeyboardEvent) => {\n    const map: Record<string, () => void> = {\n      ArrowUp: () => change(1),\n      ArrowRight: () => change(1),\n      ArrowDown: () => change(-1),\n      ArrowLeft: () => change(-1),\n      PageUp: () => change(10),\n      PageDown: () => change(-10),\n      Home: () => change((min - valueRef.current) / step),\n      End: () => change((max - valueRef.current) / step),\n    }\n    if (!map[e.key]) return\n    e.preventDefault()\n    map[e.key]()\n  }\n\n  const text = Math.abs(current).toFixed(places)\n  const chars = text.split(\"\")\n  const s = sizes[size]\n\n  const button = (delta: number, label: string, icon: React.ReactNode, disabled: boolean) => (\n    <motion.button\n      type=\"button\"\n      aria-label={label}\n      aria-disabled={disabled}\n      onPointerDown={(e) => startHold(e, delta)}\n      onPointerUp={stopHold}\n      onPointerLeave={stopHold}\n      onPointerCancel={stopHold}\n      onClick={(e) => {\n        if (e.detail === 0) change(delta)\n      }}\n      whileTap={reduced ? undefined : { scale: 0.82 }}\n      transition={{ type: \"spring\", stiffness: 600, damping: 15 }}\n      className={cn(\n        \"grid shrink-0 place-items-center rounded-full bg-background text-foreground shadow-sm ring-1 ring-border transition-opacity hover:bg-accent dark:bg-accent dark:hover:bg-accent/70\",\n        s.button,\n        disabled && \"opacity-40\"\n      )}\n    >\n      {icon}\n    </motion.button>\n  )\n\n  return (\n    <div\n      ref={scope}\n      className={cn(\"inline-flex select-none items-center rounded-full border bg-muted\", s.root, className)}\n    >\n      {button(-1, \"Decrease\", <Minus />, current <= min)}\n      <div\n        role=\"spinbutton\"\n        tabIndex={0}\n        aria-label={ariaLabel}\n        aria-valuenow={current}\n        aria-valuemin={min}\n        aria-valuemax={max}\n        aria-valuetext={`${prefix ?? \"\"}${current}${suffix ?? \"\"}`}\n        onKeyDown={onKeyDown}\n        className={cn(\n          \"flex h-full items-center justify-center rounded-full px-1 font-semibold tabular-nums text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n          s.value\n        )}\n      >\n        {prefix && <span className=\"mr-0.5 text-muted-foreground\">{prefix}</span>}\n        {current < 0 && <span>-</span>}\n        {chars.map((char, i) => {\n          const position = chars.length - i\n          return (\n            <span key={position} className=\"relative inline-flex h-[1.25em] overflow-hidden leading-[1.25em]\">\n              <AnimatePresence mode=\"popLayout\" initial={false} custom={dir}>\n                <motion.span\n                  key={char}\n                  custom={dir}\n                  variants={{\n                    enter: (d: number) => ({ y: reduced ? 0 : d > 0 ? \"100%\" : \"-100%\", opacity: 0 }),\n                    center: { y: \"0%\", opacity: 1 },\n                    exit: (d: number) => ({ y: reduced ? 0 : d > 0 ? \"-100%\" : \"100%\", opacity: 0 }),\n                  }}\n                  initial=\"enter\"\n                  animate=\"center\"\n                  exit=\"exit\"\n                  transition={reduced ? { duration: 0 } : { type: \"spring\", stiffness: 420, damping: 32 }}\n                  className=\"inline-block\"\n                >\n                  {char}\n                </motion.span>\n              </AnimatePresence>\n            </span>\n          )\n        })}\n        {suffix && <span className=\"ml-1 text-[0.8em] font-medium text-muted-foreground\">{suffix}</span>}\n      </div>\n      {button(1, \"Increase\", <Plus />, current >= max)}\n    </div>\n  )\n}\n",
      "type": "registry:component"
    }
  ],
  "docs": "Docs, live playground and every prop: https://trytweenly.vercel.app/docs/components/number-stepper",
  "type": "registry:component"
}