{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "quantity-stepper",
  "title": "Quantity Stepper",
  "description": "An animated quantity input for carts and forms with rolling digits, hold-to-repeat acceleration, and a boundary shake",
  "dependencies": [
    "framer-motion",
    "lucide-react"
  ],
  "files": [
    {
      "path": "app/registry/quantity-stepper/quantity-stepper.tsx",
      "content": "/**\n * Spectrum UI — QuantityStepper\n *\n * An animated quantity input for carts and forms. A rounded pill holds a\n * minus button, a rolling value and a plus button. Changing the value rolls\n * the digits vertically in the direction of travel, pressing a button gives\n * it a quick squish, and pushing past min or max shakes the value with a\n * brief rose tint. Holding a button repeats the step with acceleration.\n * Works controlled or uncontrolled, honors prefers-reduced-motion, and is a\n * full keyboard-operable ARIA spinbutton.\n *\n * Dependencies: framer-motion, lucide-react, @/lib/utils\n *\n * @example\n * <QuantityStepper defaultValue={1} min={1} max={10} onValueChange={(qty) => update(qty)} />\n */\n\n\"use client\"\n\nimport React, { useCallback, useEffect, useRef, useState } from \"react\"\nimport {\n  AnimatePresence,\n  motion,\n  useAnimationControls,\n  useReducedMotion,\n} from \"framer-motion\"\nimport { Minus, Plus } from \"lucide-react\"\nimport { cn } from \"@/lib/utils\"\n\n// ─── Types ───────────────────────────────────────────────────────────────────\n\nexport interface QuantityStepperProps {\n  /** Controlled value. Leave undefined for uncontrolled usage */\n  value?: number\n  /** Initial value when uncontrolled. Default 1 */\n  defaultValue?: number\n  /** Fires with the next clamped 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 press. Default 1 */\n  step?: number\n  /** Visual size of the stepper. Default \"md\" */\n  size?: \"sm\" | \"md\" | \"lg\"\n  /** Disables pointer and keyboard interaction */\n  disabled?: boolean\n  className?: string\n}\n\n// ─── Constants ───────────────────────────────────────────────────────────────\n\nconst HOLD_DELAY = 400\nconst HOLD_INTERVAL = 80\nconst HOLD_FAST_INTERVAL = 40\nconst HOLD_FAST_AFTER = 10\nconst FLASH_DURATION = 400\n\n/** Snappy spring for the press squish on the +/- buttons */\nconst SPRING_SNAPPY = { type: \"spring\", stiffness: 500, damping: 30 } as const\n\n/** Spring for a single-press digit roll */\nconst ROLL_SPRING = { type: \"spring\", stiffness: 400, damping: 30 } as const\n\n/** Short tween for the digit roll while hold-to-repeat is firing, so\n *  rolls never queue up behind the repeat rate */\nconst ROLL_REPEAT_TRANSITION = { duration: 0.1, ease: \"easeOut\" } as const\n\n/** Boundary shake: tight and small (max 4px, 300ms) */\nconst SHAKE_KEYFRAMES = [0, -4, 4, -2, 2, 0]\nconst SHAKE_DURATION = 0.3\n\nconst SIZES = {\n  sm: {\n    container: \"h-8 gap-0.5 px-1\",\n    button: \"h-6 w-6\",\n    icon: 13,\n    value: \"h-6 min-w-7 text-xs\",\n  },\n  md: {\n    container: \"h-10 gap-1 px-1\",\n    button: \"h-8 w-8\",\n    icon: 15,\n    value: \"h-8 min-w-8 text-sm\",\n  },\n  lg: {\n    container: \"h-12 gap-1 px-1.5\",\n    button: \"h-10 w-10\",\n    icon: 17,\n    value: \"h-10 min-w-10 text-base\",\n  },\n} as const\n\n// Incrementing (direction 1): the new value slides up in from below.\n// Decrementing (direction -1): it drops in from above.\nconst valueVariants = {\n  enter: (direction: number) => ({ y: direction * 12, opacity: 0 }),\n  center: { y: 0, opacity: 1 },\n  exit: (direction: number) => ({ y: direction * -12, opacity: 0 }),\n}\n\nfunction clamp(value: number, min: number, max: number) {\n  return Math.min(max, Math.max(min, value))\n}\n\n// ─── Component ───────────────────────────────────────────────────────────────\n\nexport function QuantityStepper({\n  value: valueProp,\n  defaultValue = 1,\n  onValueChange,\n  min = 0,\n  max = 99,\n  step = 1,\n  size = \"md\",\n  disabled = false,\n  className,\n}: QuantityStepperProps) {\n  const shouldReduceMotion = useReducedMotion()\n  const [internalValue, setInternalValue] = useState(() =>\n    clamp(defaultValue, min, max),\n  )\n  // 1 while incrementing, -1 while decrementing; drives the roll variants\n  const [direction, setDirection] = useState(1)\n  // Incremented on every blocked press; retriggers the shake and times the tint\n  const [flash, setFlash] = useState(0)\n  // True while hold-to-repeat is firing; switches the roll to a short tween\n  const [repeating, setRepeating] = useState(false)\n  // Pointer-only announcements; keyboard changes are announced by the\n  // spinbutton's aria-valuenow, so a live region there would double-announce\n  const [announcement, setAnnouncement] = useState(\"\")\n  const shakeControls = useAnimationControls()\n\n  const current = clamp(valueProp ?? internalValue, min, max)\n  const atMin = current <= min\n  const atMax = current >= max\n  const { container, button, icon, value: valueClasses } = SIZES[size]\n\n  // Latest value for hold-to-repeat callbacks that outlive a render\n  const valueRef = useRef(current)\n  useEffect(() => {\n    valueRef.current = current\n  }, [current])\n\n  const delayRef = useRef<ReturnType<typeof setTimeout> | null>(null)\n  const repeatRef = useRef<ReturnType<typeof setInterval> | null>(null)\n  const repeatCountRef = useRef(0)\n\n  const stopRepeat = useCallback(() => {\n    if (delayRef.current !== null) {\n      clearTimeout(delayRef.current)\n      delayRef.current = null\n    }\n    if (repeatRef.current !== null) {\n      clearInterval(repeatRef.current)\n      repeatRef.current = null\n    }\n    repeatCountRef.current = 0\n    setRepeating(false)\n  }, [])\n\n  useEffect(() => stopRepeat, [stopRepeat])\n\n  // Every blocked press restarts the shake from rest — the roll is forced\n  // instant while flash > 0 (see the roll transition), so they never overlap\n  useEffect(() => {\n    if (flash === 0) return\n    if (!shouldReduceMotion) {\n      shakeControls.set({ x: 0 })\n      void shakeControls.start({\n        x: SHAKE_KEYFRAMES,\n        transition: { duration: SHAKE_DURATION, ease: \"easeInOut\" },\n      })\n    }\n    const id = setTimeout(() => setFlash(0), FLASH_DURATION)\n    return () => clearTimeout(id)\n  }, [flash, shouldReduceMotion, shakeControls])\n\n  /** Applies a delta; returns false (and flashes) when blocked at a boundary */\n  const stepBy = useCallback(\n    (delta: number) => {\n      const next = clamp(valueRef.current + delta, min, max)\n      if (next === valueRef.current) {\n        setFlash((count) => count + 1)\n        setAnnouncement(\n          delta < 0 ? `Minimum quantity is ${min}` : `Maximum quantity is ${max}`,\n        )\n        return false\n      }\n      valueRef.current = next\n      setDirection(delta > 0 ? 1 : -1)\n      if (valueProp === undefined) setInternalValue(next)\n      onValueChange?.(next)\n      setAnnouncement(`Quantity ${next}`)\n      return true\n    },\n    [min, max, valueProp, onValueChange],\n  )\n\n  const startHold = useCallback(\n    (delta: number) => (event: React.PointerEvent<HTMLButtonElement>) => {\n      if (disabled) return\n      if (event.pointerType === \"mouse\" && event.button !== 0) return\n      stopRepeat()\n      // Stop immediately when the first press is already blocked at a boundary\n      if (!stepBy(delta)) return\n      delayRef.current = setTimeout(() => {\n        setRepeating(true)\n        const tick = () => {\n          // Repeat also stops at boundaries, not only on pointer up/leave\n          if (!stepBy(delta)) {\n            stopRepeat()\n            return\n          }\n          repeatCountRef.current += 1\n          if (repeatCountRef.current === HOLD_FAST_AFTER) {\n            if (repeatRef.current !== null) clearInterval(repeatRef.current)\n            repeatRef.current = setInterval(tick, HOLD_FAST_INTERVAL)\n          }\n        }\n        repeatRef.current = setInterval(tick, HOLD_INTERVAL)\n      }, HOLD_DELAY)\n    },\n    [disabled, stepBy, stopRepeat],\n  )\n\n  const handleKeyDown = useCallback(\n    (event: React.KeyboardEvent<HTMLSpanElement>) => {\n      if (disabled) return\n      let next: number\n      switch (event.key) {\n        case \"ArrowUp\":\n          next = current + step\n          break\n        case \"ArrowDown\":\n          next = current - step\n          break\n        case \"PageUp\":\n          next = current + step * 10\n          break\n        case \"PageDown\":\n          next = current - step * 10\n          break\n        case \"Home\":\n          next = min\n          break\n        case \"End\":\n          next = max\n          break\n        default:\n          return\n      }\n      event.preventDefault()\n      const clamped = clamp(next, min, max)\n      if (clamped === current) {\n        // Only shake when pushing past a boundary, not when already at Home/End\n        if (event.key !== \"Home\" && event.key !== \"End\") {\n          setFlash((count) => count + 1)\n          // aria-valuenow does not change here, so the live region fills in\n          setAnnouncement(\n            next < current\n              ? `Minimum quantity is ${min}`\n              : `Maximum quantity is ${max}`,\n          )\n        }\n        return\n      }\n      valueRef.current = clamped\n      setDirection(clamped > current ? 1 : -1)\n      if (valueProp === undefined) setInternalValue(clamped)\n      onValueChange?.(clamped)\n    },\n    [disabled, current, step, min, max, valueProp, onValueChange],\n  )\n\n  const buttonClasses = cn(\n    \"flex shrink-0 touch-manipulation items-center justify-center rounded-full text-neutral-600 transition-colors\",\n    \"hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-800\",\n    \"focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-neutral-950 dark:focus-visible:ring-neutral-300\",\n    button,\n  )\n\n  // The squish rides the press state (whileTap), so it holds steady during\n  // hold-to-repeat instead of re-triggering on every repeated step\n  const pressSquish =\n    shouldReduceMotion || disabled ? undefined : { scale: 0.85 }\n\n  const rollTransition = shouldReduceMotion\n    ? { duration: 0 }\n    : flash > 0\n      ? // Never roll while the boundary shake is running\n        { duration: 0 }\n      : repeating\n        ? ROLL_REPEAT_TRANSITION\n        : ROLL_SPRING\n\n  return (\n    <div\n      role=\"group\"\n      aria-label=\"Quantity\"\n      className={cn(\n        \"inline-flex select-none items-center rounded-full bg-white\",\n        \"shadow-[0px_0px_0px_1px_rgba(0,0,0,0.06),0px_1px_2px_0px_rgba(0,0,0,0.04),0px_2px_4px_0px_rgba(0,0,0,0.04)]\",\n        \"dark:border dark:border-neutral-800 dark:bg-neutral-900 dark:shadow-none\",\n        disabled && \"pointer-events-none opacity-50\",\n        container,\n        className,\n      )}\n    >\n      <motion.button\n        type=\"button\"\n        tabIndex={-1}\n        disabled={disabled}\n        aria-label=\"Decrease quantity\"\n        onPointerDown={startHold(-step)}\n        onPointerUp={stopRepeat}\n        onPointerLeave={stopRepeat}\n        onPointerCancel={stopRepeat}\n        whileTap={pressSquish}\n        transition={shouldReduceMotion ? { duration: 0 } : SPRING_SNAPPY}\n        className={cn(buttonClasses, atMin && \"opacity-40\")}\n      >\n        <Minus size={icon} aria-hidden=\"true\" />\n      </motion.button>\n\n      <motion.span\n        role=\"spinbutton\"\n        aria-valuenow={current}\n        aria-valuemin={min}\n        aria-valuemax={max}\n        aria-label=\"Quantity\"\n        tabIndex={disabled ? -1 : 0}\n        onKeyDown={handleKeyDown}\n        animate={shakeControls}\n        className={cn(\n          \"flex items-center justify-center overflow-hidden px-1 font-medium tabular-nums transition-colors\",\n          \"focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-neutral-950 dark:focus-visible:ring-neutral-300\",\n          flash > 0\n            ? \"text-rose-500 dark:text-rose-400\"\n            : \"text-neutral-900 dark:text-neutral-100\",\n          valueClasses,\n        )}\n      >\n        <AnimatePresence mode=\"popLayout\" initial={false} custom={direction}>\n          <motion.span\n            key={current}\n            className=\"inline-block\"\n            custom={direction}\n            variants={valueVariants}\n            initial=\"enter\"\n            animate=\"center\"\n            exit=\"exit\"\n            transition={rollTransition}\n          >\n            {current}\n          </motion.span>\n        </AnimatePresence>\n      </motion.span>\n\n      <motion.button\n        type=\"button\"\n        tabIndex={-1}\n        disabled={disabled}\n        aria-label=\"Increase quantity\"\n        onPointerDown={startHold(step)}\n        onPointerUp={stopRepeat}\n        onPointerLeave={stopRepeat}\n        onPointerCancel={stopRepeat}\n        whileTap={pressSquish}\n        transition={shouldReduceMotion ? { duration: 0 } : SPRING_SNAPPY}\n        className={cn(buttonClasses, atMax && \"opacity-40\")}\n      >\n        <Plus size={icon} aria-hidden=\"true\" />\n      </motion.button>\n\n      {/* Speaks pointer-driven changes and boundary blocks; successful keyboard\n          changes are already announced through the spinbutton's aria-valuenow */}\n      <span className=\"sr-only\" aria-live=\"polite\">\n        {announcement}\n      </span>\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/spectrumui/quantity-stepper.tsx"
    }
  ],
  "type": "registry:component"
}
