{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"scrub-input","type":"registry:component","title":"Scrub input","description":"Drag the label and a tape measure runs under the needle, or just type.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":[],"registryDependencies":["utils"],"files":[{"path":"components/scrub-input.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useEffect, useId, useLayoutEffect, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\n// Below this the press is a click on the label (which focuses the field),\n// not the start of a scrub.\nconst DRAG_THRESHOLD = 3;\n// Spacing of the tape's minor ticks; majors land every fifth.\nconst TICK = 6;\n\ntype Scrub = {\n  id: number;\n  startX: number;\n  lastX: number;\n  // Unrounded running value, so slow fine drags accumulate instead of\n  // rounding every sub-step away.\n  raw: number;\n  start: number;\n  sent: number;\n  moved: boolean;\n};\n\nfunction decimalsOf(n: number) {\n  const s = String(n);\n  const dot = s.indexOf(\".\");\n  return dot < 0 ? 0 : s.length - dot - 1;\n}\n\n// Shift is a coarse gear, Alt/Option a fine one, like Figma.\nfunction gear(e: { shiftKey: boolean; altKey: boolean }) {\n  return e.shiftKey ? 10 : e.altKey ? 0.1 : 1;\n}\n\nexport function ScrubInput({\n  label,\n  name,\n  value,\n  onChange,\n  min = -Infinity,\n  max = Infinity,\n  step = 1,\n  // One more decimal than the step, so the fine gear has room to land.\n  precision = decimalsOf(step) + 1,\n  pixelsPerStep = 1,\n  suffix = \"\",\n  lockPointer = false,\n  className,\n}: {\n  label: React.ReactNode;\n  name: string;\n  value: number;\n  onChange: (value: number) => void;\n  min?: number;\n  max?: number;\n  step?: number;\n  precision?: number;\n  pixelsPerStep?: number;\n  suffix?: string;\n  // Opt-in: browsers announce pointer lock with a banner, which is too loud\n  // for a quick nudge but worth it for long scrubs past the screen edge.\n  lockPointer?: boolean;\n  className?: string;\n}) {\n  const id = useId();\n  const labelRef = useRef<HTMLLabelElement>(null);\n  const inputRef = useRef<HTMLInputElement>(null);\n  const scrub = useRef<Scrub | null>(null);\n  const suppressClick = useRef(false);\n  const skipCommit = useRef(false);\n  const selectNext = useRef(false);\n  const [scrubbing, setScrubbing] = useState(false);\n  // What the user is typing; null means the field shows the live value.\n  const [draft, setDraft] = useState<string | null>(null);\n\n  // The tape pattern repeats every major tick, so only the remainder\n  // matters, which also keeps huge values from losing precision.\n  const period = TICK * 5;\n  const tape = ((((value / step) * pixelsPerStep) % period) + period) % period;\n\n  const clamp = (n: number) => Math.min(Math.max(n, min), max);\n  const round = (n: number) => Number(n.toFixed(precision));\n  const format = (n: number) => `${round(n)}${suffix}`;\n  const parse = (text: string) => {\n    const trimmed = (suffix ? text.replace(suffix, \"\") : text).trim();\n    const n = Number(trimmed);\n    return trimmed === \"\" || Number.isNaN(n) ? null : clamp(round(n));\n  };\n\n  // Re-selects after React writes the new value, which would otherwise\n  // drop the caret at the end.\n  useLayoutEffect(() => {\n    if (!selectNext.current) return;\n    selectNext.current = false;\n    inputRef.current?.select();\n  }, [draft]);\n\n  const endScrub = () => {\n    if (document.pointerLockElement === labelRef.current) {\n      document.exitPointerLock();\n    }\n    document.documentElement.style.removeProperty(\"cursor\");\n    suppressClick.current = scrub.current?.moved ?? false;\n    scrub.current = null;\n    setScrubbing(false);\n  };\n\n  // Escape mid-scrub puts the value back where the drag started. Only\n  // subscribed while scrubbing.\n  useEffect(() => {\n    if (!scrubbing) return;\n    const onKey = (e: KeyboardEvent) => {\n      if (e.key !== \"Escape\" || !scrub.current) return;\n      onChange(scrub.current.start);\n      endScrub();\n    };\n    window.addEventListener(\"keydown\", onKey);\n    return () => window.removeEventListener(\"keydown\", onKey);\n  });\n\n  useEffect(\n    () => () => {\n      if (!scrub.current) return;\n      if (document.pointerLockElement) document.exitPointerLock();\n      document.documentElement.style.removeProperty(\"cursor\");\n    },\n    [],\n  );\n\n  const commit = () => {\n    if (draft === null) return value;\n    const next = parse(draft);\n    if (next !== null && next !== value) onChange(next);\n    return next ?? value;\n  };\n\n  const stepBy = (delta: number) => {\n    const base = draft === null ? value : (parse(draft) ?? value);\n    const next = clamp(round(base + delta));\n    if (next !== value) onChange(next);\n    selectNext.current = true;\n    setDraft(format(next));\n  };\n\n  return (\n    <div\n      className={cn(\n        \"relative flex h-10 items-center overflow-hidden rounded-lg bg-background outline-foreground has-[input:focus-visible]:outline-2 has-[input:focus-visible]:outline-solid\",\n        className,\n      )}\n    >\n      {/* A tape measure surfaces along the bottom edge while scrubbing and\n          runs under a fixed needle, higher values arriving from the right,\n          like the dial on a lens. One step of value is one pixel of tape\n          (at the normal gear), so the ticks travel exactly as far as the\n          pointer does: the number changing is visibly the tape moving. */}\n      <span\n        aria-hidden\n        className={cn(\n          \"pointer-events-none absolute inset-x-0 bottom-0 h-2 text-muted-foreground\",\n          \"transition-[opacity,translate] ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:translate-y-0\",\n          scrubbing\n            ? \"translate-y-0 opacity-100 duration-150\"\n            : \"translate-y-1 opacity-0 duration-200\",\n        )}\n        style={{\n          // Fades the tape out toward both ends, so it reads as endless.\n          maskImage:\n            \"linear-gradient(to right, transparent, #000 30%, #000 70%, transparent)\",\n        }}\n      >\n        <span\n          className=\"absolute inset-0\"\n          style={{\n            // Minor ticks every 6px, a taller major tick every 30px.\n            backgroundImage:\n              \"linear-gradient(to right, currentColor 1px, transparent 1px), linear-gradient(to right, currentColor 1px, transparent 1px)\",\n            backgroundSize: `${TICK}px 3px, ${TICK * 5}px 6px`,\n            backgroundRepeat: \"repeat-x\",\n            backgroundPositionY: \"bottom\",\n            backgroundPositionX: `calc(50% - ${tape}px)`,\n          }}\n        />\n        <span className=\"absolute bottom-0 left-1/2 h-2 w-px -translate-x-1/2 bg-foreground\" />\n      </span>\n      <label\n        ref={labelRef}\n        htmlFor={id}\n        className={cn(\n          \"flex h-full w-9 shrink-0 cursor-ew-resize touch-none items-center justify-center text-sm text-muted-foreground transition-[color] duration-150 ease-out select-none hover:text-foreground\",\n          scrubbing && \"text-foreground\",\n        )}\n        onPointerDown={(e) => {\n          // Ignores a second finger rather than letting it hijack the drag.\n          if (e.button !== 0 || scrub.current) return;\n          // Keeps the press from selecting text or moving focus yet.\n          e.preventDefault();\n          let start = value;\n          if (document.activeElement === inputRef.current) {\n            start = commit();\n            skipCommit.current = true;\n            inputRef.current?.blur();\n          }\n          e.currentTarget.setPointerCapture(e.pointerId);\n          scrub.current = {\n            id: e.pointerId,\n            startX: e.clientX,\n            lastX: e.clientX,\n            raw: start,\n            start,\n            sent: start,\n            moved: false,\n          };\n        }}\n        onPointerMove={(e) => {\n          const s = scrub.current;\n          if (!s || e.pointerId !== s.id) return;\n          if (!s.moved) {\n            if (Math.abs(e.clientX - s.startX) < DRAG_THRESHOLD) return;\n            s.moved = true;\n            setScrubbing(true);\n            // Keeps the resize cursor once the pointer leaves the label.\n            document.documentElement.style.cursor = \"ew-resize\";\n            if (lockPointer && e.pointerType === \"mouse\") {\n              // Older browsers return nothing, newer ones a promise that\n              // rejects when lock is refused. Either way the captured\n              // pointer below keeps scrubbing.\n              Promise.resolve(e.currentTarget.requestPointerLock?.()).catch(\n                () => {},\n              );\n            }\n          }\n          // A locked pointer stays put, so only movementX reports travel.\n          const locked = document.pointerLockElement === e.currentTarget;\n          const dx = locked ? e.movementX : e.clientX - s.lastX;\n          s.lastX = e.clientX;\n          s.raw = clamp(s.raw + (dx / pixelsPerStep) * step * gear(e));\n          const next = round(s.raw);\n          // Renders only when the shown number changes, not every pixel.\n          if (next === s.sent) return;\n          s.sent = next;\n          onChange(next);\n        }}\n        onPointerUp={(e) => {\n          if (scrub.current?.id === e.pointerId) endScrub();\n        }}\n        onPointerCancel={(e) => {\n          if (scrub.current?.id === e.pointerId) endScrub();\n        }}\n        onClick={(e) => {\n          // The click that ends a scrub shouldn't also drop you into typing.\n          if (suppressClick.current) e.preventDefault();\n          suppressClick.current = false;\n        }}\n      >\n        {label}\n      </label>\n      <input\n        ref={inputRef}\n        id={id}\n        type=\"text\"\n        role=\"spinbutton\"\n        inputMode=\"decimal\"\n        autoComplete=\"off\"\n        spellCheck={false}\n        aria-label={name}\n        aria-valuenow={value}\n        aria-valuetext={format(value)}\n        aria-valuemin={Number.isFinite(min) ? min : undefined}\n        aria-valuemax={Number.isFinite(max) ? max : undefined}\n        value={draft ?? format(value)}\n        className=\"h-full min-w-0 flex-1 bg-transparent pr-2.5 text-sm text-foreground tabular-nums outline-hidden\"\n        onFocus={(e) => {\n          setDraft(format(value));\n          e.currentTarget.select();\n        }}\n        onChange={(e) => setDraft(e.target.value)}\n        onBlur={() => {\n          if (!skipCommit.current) commit();\n          skipCommit.current = false;\n          setDraft(null);\n        }}\n        onKeyDown={(e) => {\n          if (e.key === \"Enter\") {\n            e.preventDefault();\n            // Stays in the field with the result selected, so the next\n            // edit is one keystroke away.\n            const next = commit();\n            selectNext.current = true;\n            setDraft(format(next));\n          } else if (e.key === \"Escape\") {\n            skipCommit.current = true;\n            e.currentTarget.blur();\n          } else if (e.key === \"ArrowUp\" || e.key === \"ArrowDown\") {\n            e.preventDefault();\n            stepBy((e.key === \"ArrowUp\" ? step : -step) * gear(e));\n          }\n        }}\n      />\n    </div>\n  );\n}\n\nfunction AngleIcon() {\n  return (\n    <svg\n      viewBox=\"0 0 16 16\"\n      className=\"size-4\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth={1.5}\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      aria-hidden\n    >\n      <path d=\"M3 3v10h10\" />\n      <path d=\"M3 7.5a5.5 5.5 0 0 1 5.5 5.5\" />\n    </svg>\n  );\n}\n\nexport default function ScrubInputDemo() {\n  const [x, setX] = useState(120);\n  const [y, setY] = useState(48);\n  const [rotation, setRotation] = useState(0);\n\n  return (\n    // 18px outer radius over 10px padding keeps the 8px fields concentric.\n    <div className=\"w-[380px] max-w-full rounded-[18px] bg-muted p-2.5 shadow-raised\">\n      <div className=\"px-2 pt-0.5 pb-2.5 text-sm font-medium text-foreground\">\n        Transform\n      </div>\n      <div className=\"grid grid-cols-2 gap-2.5\">\n        <ScrubInput label=\"X\" name=\"X position\" value={x} onChange={setX} />\n        <ScrubInput label=\"Y\" name=\"Y position\" value={y} onChange={setY} />\n        <ScrubInput\n          label={<AngleIcon />}\n          name=\"Rotation\"\n          value={rotation}\n          onChange={setRotation}\n          min={-180}\n          max={180}\n          suffix=\"°\"\n        />\n      </div>\n    </div>\n  );\n}\n"}],"cssVars":{"light":{"shadow-raised":"0 0 0 1px oklch(0 0 0 / 0.06), 0 1px 2px oklch(0 0 0 / 0.06), 0 6px 16px -6px oklch(0 0 0 / 0.12)"},"dark":{"shadow-raised":"0 0 0 1px oklch(1 0 0 / 0.08), 0 1px 2px oklch(0 0 0 / 0.4), 0 6px 16px -6px oklch(0 0 0 / 0.6)"},"theme":{"shadow-raised":"var(--shadow-raised)"}},"categories":["inputs"],"docs":"From ui lab: https://lab.xevrion.dev/lab/scrub-input"}