{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "scrub-number-field",
  "title": "Scrub Number Field",
  "author": "henriquegpb <https://github.com/henriquegpb>",
  "description": "A number field you drag to scrub and click to type, with a ruler that tracks the gesture.",
  "files": [
    {
      "path": "registry/microkit/scrub-number-field.tsx",
      "content": "\"use client\";\n\nimport { useRef, useState } from \"react\";\nimport type { AnimationEvent as ReactAnimationEvent, KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent } from \"react\";\n\nconst MIN = 0;\nconst MAX = 100;\nconst PIXELS_PER_STEP = 2;\nconst DRAG_THRESHOLD = 3;\n\n/* The two speeds a design tool trains your hands to expect, on the keys it trains them to reach for. */\nconst stepFor = (event: { altKey: boolean; shiftKey: boolean }) => (event.shiftKey ? 10 : event.altKey ? 0.1 : 1);\nconst round = (value: number) => Math.round(value * 10) / 10;\nconst clamp = (value: number) => Math.min(MAX, Math.max(MIN, value));\nconst numeric = (raw: string) => raw.replace(/[^\\d.]/g, \"\").replace(/(\\..*)\\./g, \"$1\");\n\nconst nudgeKeyframes = `\n@keyframes scrub-number-nudge {\n  0%, 100% { transform: translateX(0); }\n  40% { transform: translateX(var(--scrub-nudge)); }\n}\n@media (prefers-reduced-motion: reduce) {\n  @keyframes scrub-number-nudge {\n    0%, 100% { transform: none; }\n  }\n}`;\n\nexport function ScrubNumberField() {\n  const [value, setValue] = useState(48);\n  const [draft, setDraft] = useState<string | null>(null);\n  const [scrubbing, setScrubbing] = useState(false);\n  const [bound, setBound] = useState<\"min\" | \"max\" | null>(null);\n  const inputRef = useRef<HTMLInputElement>(null);\n  const drag = useRef({ origin: 0, base: 48, value: 48, step: 1, pinned: \"\", moved: false });\n  const reverting = useRef(false);\n\n  const startScrub = (event: ReactPointerEvent<HTMLDivElement>) => {\n    /* Once you are typing the field is a text input again, and dragging selects characters the way it should. */\n    if (event.button !== 0 || document.activeElement === inputRef.current) return;\n    event.preventDefault();\n    event.currentTarget.setPointerCapture(event.pointerId);\n    drag.current = { origin: event.clientX, base: value, value, step: stepFor(event), pinned: \"\", moved: false };\n  };\n\n  const moveScrub = (event: ReactPointerEvent<HTMLDivElement>) => {\n    if (!event.currentTarget.hasPointerCapture(event.pointerId)) return;\n\n    /* Reaching for Shift mid-drag re-bases the gesture instead of multiplying the pixels already travelled. */\n    const held = stepFor(event);\n    if (held !== drag.current.step) {\n      drag.current = { ...drag.current, origin: event.clientX, base: drag.current.value, step: held, moved: true };\n    }\n\n    const travel = event.clientX - drag.current.origin;\n    if (!drag.current.moved && Math.abs(travel) < DRAG_THRESHOLD) return;\n    drag.current.moved = true;\n    setScrubbing(true);\n\n    const wanted = round(drag.current.base + Math.round(travel / PIXELS_PER_STEP) * held);\n    const pinned = wanted > MAX ? \"max\" : wanted < MIN ? \"min\" : \"\";\n    if (pinned && pinned !== drag.current.pinned) setBound(pinned);\n    drag.current.pinned = pinned;\n    drag.current.value = clamp(wanted);\n    setValue(drag.current.value);\n  };\n\n  const endScrub = (event: ReactPointerEvent<HTMLDivElement>) => {\n    if (!event.currentTarget.hasPointerCapture(event.pointerId)) return;\n    event.currentTarget.releasePointerCapture(event.pointerId);\n    setScrubbing(false);\n    /* A press that never travelled is a click, and a click hands the field over for typing. */\n    if (!drag.current.moved) {\n      inputRef.current?.focus();\n      inputRef.current?.select();\n    }\n  };\n\n  const settle = (next: number) => {\n    const clamped = clamp(round(next));\n    if (clamped !== round(next)) setBound(clamped === MIN ? \"min\" : \"max\");\n    drag.current.value = clamped;\n    setValue(clamped);\n    setDraft(null);\n  };\n\n  const commit = () => {\n    const parsed = draft === null ? Number.NaN : Number.parseFloat(draft);\n    /* Escape blurs the field, and the blur that follows must not commit the draft Escape just threw away. */\n    if (!reverting.current && Number.isFinite(parsed)) settle(parsed);\n    reverting.current = false;\n    setDraft(null);\n  };\n\n  const handleKey = (event: ReactKeyboardEvent<HTMLInputElement>) => {\n    if (event.key === \"Enter\") {\n      event.preventDefault();\n      commit();\n      event.currentTarget.blur();\n      return;\n    }\n    if (event.key === \"Escape\") {\n      reverting.current = true;\n      event.currentTarget.blur();\n      return;\n    }\n    const direction = event.key === \"ArrowUp\" ? 1 : event.key === \"ArrowDown\" ? -1 : 0;\n    if (!direction) return;\n    event.preventDefault();\n    settle(value + direction * stepFor(event));\n  };\n\n  /* The nudge clears itself when it finishes, so a bound can be hit again the moment the field is still. */\n  const clearBound = (event: ReactAnimationEvent<HTMLDivElement>) => {\n    if (event.animationName === \"scrub-number-nudge\") setBound(null);\n  };\n\n  return (\n    <>\n      <style>{nudgeKeyframes}</style>\n      <div\n        className=\"flex h-8 w-[96px] cursor-ew-resize touch-none items-center rounded-[7px] border border-[light-dark(#cbd2dc,#363a42)] bg-[light-dark(#ffffff,#15171b)] px-[10px] transition-[border-color,box-shadow] duration-200 ease-[ease] [--scrub-nudge:5px] focus-within:cursor-text focus-within:border-[#f97316] focus-within:shadow-[0_0_0_3px_#f9731625] data-[scrubbing]:border-[#f97316] data-[scrubbing]:shadow-[0_0_0_3px_#f9731625] data-[bound]:border-[#f97316] data-[bound]:[animation:scrub-number-nudge_.26s_cubic-bezier(.22,1,.36,1)] data-[bound=min]:[--scrub-nudge:-5px]\"\n        data-scrubbing={scrubbing || undefined}\n        data-bound={bound ?? undefined}\n        onAnimationEnd={clearBound}\n        onPointerDown={startScrub}\n        onPointerMove={moveScrub}\n        onPointerUp={endScrub}\n        onPointerCancel={endScrub}\n      >\n        <input\n          ref={inputRef}\n          className=\"w-full min-w-0 cursor-[inherit] border-0 bg-transparent p-0 font-mono text-[13px] font-medium leading-none tabular-nums text-[light-dark(#262d38,#e8ebee)] outline-0 focus:cursor-text focus:outline-0 focus-visible:outline-0\"\n          type=\"text\"\n          inputMode=\"decimal\"\n          autoComplete=\"off\"\n          aria-label=\"Opacity\"\n          value={draft ?? String(value)}\n          onChange={(event) => setDraft(numeric(event.target.value))}\n          onKeyDown={handleKey}\n          onBlur={commit}\n        />\n      </div>\n    </>\n  );\n}\n",
      "type": "registry:component",
      "target": "@components/microkit/scrub-number-field.tsx"
    }
  ],
  "docs": "Live preview and the CSS, JavaScript and Tailwind variants: https://microkit.co/components/scrub-number-field",
  "categories": [
    "input",
    "drag"
  ],
  "type": "registry:component"
}