{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "scrub-input",
  "type": "registry:ui",
  "title": "Scrub input",
  "description": "A Figma-style number field: drag the label to scrub, or type values, units, and arithmetic.",
  "categories": [
    "inputs"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/motion-tokens.json",
    "https://irisui.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "src/registry/components/scrub-input/scrub-input.tsx",
      "content": "\"use client\";\n\nimport {\n  forwardRef,\n  useCallback,\n  useEffect,\n  useId,\n  useImperativeHandle,\n  useRef,\n  useState,\n  useSyncExternalStore,\n} from \"react\";\nimport type {\n  FocusEvent,\n  InputHTMLAttributes,\n  KeyboardEvent as ReactKeyboardEvent,\n  MouseEvent as ReactMouseEvent,\n  PointerEvent as ReactPointerEvent,\n  ReactNode,\n} from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { AnimatePresence, animate, motion, useMotionValue, useReducedMotion, useTransform } from \"motion/react\";\nimport type { Transition } from \"motion/react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport { cn } from \"../lib/utils\";\n\nexport type ScrubInputSource = \"scrub\" | \"type\" | \"key\" | \"reset\";\nexport interface ScrubInputChangeDetails {\n  /** What produced the value: a label drag, a typed entry, an arrow key, or a double click reset. */\n  source: ScrubInputSource;\n  /** The unit in effect after the change. */\n  unit: string;\n}\n\n/**\n * A compact number field for inspectors and property panels. Drag the label sideways to scrub the value (the pointer is locked when the\n * browser allows it, so a long drag never runs out of screen), or click in and type an exact value. Typed entries accept arithmetic such as\n * `12*2` or `(100-24)/2`, and a unit from `units` such as `50%`. Shift scrubs and steps ten times coarser, Alt ten times finer. At `min` and\n * `max` the field strains past the limit with rubber band resistance and springs back on release. Arrow keys step, Enter commits, Escape\n * restores the last value, and a double click on the label restores `resetValue`.\n */\nexport interface ScrubInputProps extends Omit<\n  InputHTMLAttributes<HTMLInputElement>,\n  \"value\" | \"defaultValue\" | \"onChange\" | \"type\" | \"size\" | \"min\" | \"max\" | \"step\" | \"prefix\"\n> {\n  /** Text or a 16px icon. Icon labels need `aria-label` so the field keeps an accessible name. */\n  label: ReactNode;\n  value?: number;\n  defaultValue?: number;\n  /** Fires for every change, including each step of a scrub. */\n  onValueChange?: (value: number, details: ScrubInputChangeDetails) => void;\n  /** Fires once when a change settles: a scrub ends, Enter or blur commits typing, or a key step lands. */\n  onValueCommit?: (value: number, details: ScrubInputChangeDetails) => void;\n  min?: number;\n  max?: number;\n  /** One step for arrows and scrubbing. Defaults to 1. */\n  step?: number;\n  /** Step with Alt held. Defaults to a tenth of `step`. */\n  fineStep?: number;\n  /** Step with Shift held. Defaults to ten times `step`. */\n  coarseStep?: number;\n  /** Decimal places kept. Defaults to the precision of `fineStep`. */\n  precision?: number;\n  /** Pointer travel in px for one step while scrubbing. Defaults to 2. */\n  pixelsPerStep?: number;\n  /** Values wrap around from `max` to `min` instead of stopping, as rotation does. Needs both limits. */\n  wrap?: boolean;\n  /** The unit shown after the number. With more than one entry in `units`, it becomes a button that cycles them. */\n  unit?: string;\n  defaultUnit?: string;\n  /** Units a typed value may carry, such as [\"px\", \"%\"]. Defaults to the current unit. */\n  units?: readonly string[];\n  onUnitChange?: (unit: string) => void;\n  /** Double click the label to return here. */\n  resetValue?: number;\n  /** Shows how far the value sits between `min` and `max` as a quiet fill. On by default when both limits are set. */\n  track?: boolean;\n  size?: \"sm\" | \"md\";\n  /** Locks the pointer while scrubbing when the browser allows it. On by default. */\n  pointerLock?: boolean;\n  className?: string;\n}\n\nconst { spring, duration, ease } = motionTokens;\n/** Movement before a press on the label becomes a scrub, and the most pixels the field strains past a limit. */\nconst SLOP = 3,\n  STRAIN = 12,\n  OVERSHOOT_CAP = 90;\nconst physical = (visualDuration: number, bounce: number): Transition => {\n  const root = (2 * Math.PI) / (visualDuration * 1.2);\n  return {\n    type: \"spring\",\n    stiffness: root * root,\n    damping: 2 * (1 - bounce) * root,\n    mass: 1,\n    restDelta: 0.01,\n    restSpeed: 0.5,\n  };\n};\nconst settle = physical(0.34, 0),\n  shake = physical(0.3, 0);\n/** The unit sits after the number; with several units it is a button that cycles them and gives a little under the press. */\nconst unitBase =\n  \"relative inline-flex h-[22px] min-w-[18px] flex-none items-center justify-center rounded-chip border-0 bg-transparent px-1 text-xs/[inherit] text-ink-3 [-webkit-tap-highlight-color:transparent]\";\nconst unitCycle =\n  \"cursor-pointer rounded-button px-1.5 transition-[background-color,color,scale] duration-fast ease-standard hover:enabled:bg-[color-mix(in_oklab,var(--ink)_7%,transparent)] hover:enabled:text-ink active:scale-[.94] motion-reduce:duration-instant motion-reduce:active:scale-100\";\n\nconst decimalsOf = (value: number) => {\n  const text = String(value);\n  if (text.includes(\"e-\")) return Number(text.split(\"e-\")[1]);\n  return (text.split(\".\")[1] ?? \"\").length;\n};\nconst roundTo = (value: number, places: number) => {\n  const f = 10 ** places;\n  return Math.round(value * f) / f;\n};\n/** Rubber-band resistance, as in overscroll: each extra pixel gives less, and the strain never exceeds `limit`. */\nconst rubber = (distance: number, limit = STRAIN) =>\n  Math.sign(distance) * (1 - 1 / ((Math.abs(distance) * 0.55) / limit + 1)) * limit;\nconst format = (value: number, places: number) => String(roundTo(value, places)).replace(/^-0$/, \"0\");\n\ntype Parsed = { value: number; unit?: string } | null;\n\n/** Evaluates + - * / and parentheses over numbers that may carry a unit. Returns null for anything else, or for an unknown unit. */\nexport function evaluateScrubExpression(text: string, units: readonly string[]): Parsed {\n  const source = text\n    .trim()\n    .replace(/,/g, \".\")\n    .replace(/[×x](?=\\s*[\\d(.])/gi, \"*\")\n    .replace(/÷/g, \"/\")\n    .replace(/−/g, \"-\");\n  if (!source) return null;\n  const tokens = source.match(/\\d*\\.?\\d+(?:e[+-]?\\d+)?|[a-z%°]+|[-+*/()]|\\S/gi);\n  if (!tokens) return null;\n  let index = 0,\n    unit: string | undefined,\n    bad = false;\n  const known = units.map((entry) => entry.toLowerCase());\n  const peek = () => tokens[index];\n  function primary(): number {\n    const token = tokens![index++];\n    if (token === \"(\") {\n      const inner = sum();\n      if (tokens![index++] !== \")\") bad = true;\n      return inner;\n    }\n    if (token === \"-\") return -primary();\n    if (token === \"+\") return primary();\n    if (token === undefined || !/^\\d*\\.?\\d+/.test(token)) {\n      bad = true;\n      return 0;\n    }\n    const number = Number(token);\n    const next = peek();\n    if (next && /^[a-z%°]+$/i.test(next)) {\n      index++;\n      const word = next.toLowerCase(),\n        alias = word === \"°\" ? \"deg\" : word === \"deg\" ? \"°\" : word;\n      const found = known.includes(word) ? known.indexOf(word) : known.indexOf(alias);\n      if (found < 0) bad = true;\n      else unit = units[found];\n    }\n    return number;\n  }\n  function product(): number {\n    let left = primary();\n    while (peek() === \"*\" || peek() === \"/\") {\n      const op = tokens![index++];\n      const right = primary();\n      left = op === \"*\" ? left * right : left / right;\n    }\n    return left;\n  }\n  function sum(): number {\n    let left = product();\n    while (peek() === \"+\" || peek() === \"-\") {\n      const op = tokens![index++];\n      const right = product();\n      left = op === \"+\" ? left + right : left - right;\n    }\n    return left;\n  }\n  const value = sum();\n  if (bad || index !== tokens.length || !Number.isFinite(value)) return null;\n  return { value, unit };\n}\n\nconst subscribe = () => () => {};\nfunction useHydrated() {\n  return useSyncExternalStore(\n    subscribe,\n    () => true,\n    () => false,\n  );\n}\n\ntype Drag = {\n  id: number;\n  lastX: number;\n  travel: number;\n  raw: number;\n  start: number;\n  moved: boolean;\n  locked: boolean;\n  mouse: boolean;\n};\n\nexport const ScrubInput = forwardRef<HTMLInputElement, ScrubInputProps>(function ScrubInput(\n  {\n    label,\n    value,\n    defaultValue = 0,\n    onValueChange,\n    onValueCommit,\n    min = -Infinity,\n    max = Infinity,\n    step = 1,\n    fineStep,\n    coarseStep,\n    precision,\n    pixelsPerStep = 2,\n    wrap = false,\n    unit: unitProp,\n    defaultUnit,\n    units,\n    onUnitChange,\n    resetValue,\n    track,\n    size = \"md\",\n    pointerLock = true,\n    className,\n    disabled,\n    id: idProp,\n    onFocus,\n    onBlur,\n    onKeyDown,\n    \"aria-label\": ariaLabel,\n    ...rest\n  },\n  forwarded,\n) {\n  const uid = useId();\n  const id = idProp ?? `scrub-${uid.replace(/[^a-zA-Z0-9-]/g, \"\")}`;\n  const hydrated = useHydrated();\n  const reduced = !!useReducedMotion() && hydrated;\n  const inputRef = useRef<HTMLInputElement>(null);\n  const shellRef = useRef<HTMLDivElement>(null);\n  const handleRef = useRef<HTMLLabelElement>(null);\n  useImperativeHandle(forwarded, () => inputRef.current!, []);\n\n  const fine = fineStep ?? step / 10,\n    coarse = coarseStep ?? step * 10;\n  const places = precision ?? Math.max(decimalsOf(step), decimalsOf(fine));\n  const canWrap = wrap && Number.isFinite(min) && Number.isFinite(max) && max > min;\n  const showTrack = (track ?? true) && Number.isFinite(min) && Number.isFinite(max) && max > min;\n\n  const [inner, setInner] = useState(defaultValue);\n  const current = value ?? inner;\n  const [innerUnit, setInnerUnit] = useState(defaultUnit ?? unitProp ?? units?.[0] ?? \"\");\n  const unit = unitProp ?? innerUnit;\n  const unitList = units ?? (unit ? [unit] : []);\n  const [draft, setDraft] = useState<string | null>(null);\n  const [scrubbing, setScrubbing] = useState<null | { locked: boolean; mouse: boolean }>(null);\n  const [modifier, setModifier] = useState<\"fine\" | \"coarse\" | null>(null);\n  const [invalid, setInvalid] = useState(false);\n\n  const latest = useRef({ current, unit });\n  useEffect(() => {\n    latest.current = { current, unit };\n  }, [current, unit]);\n  const drag = useRef<Drag | null>(null);\n  const suppressClick = useRef(false);\n  const invalidTimer = useRef(0);\n  useEffect(() => () => window.clearTimeout(invalidTimer.current), []);\n\n  /** Strain past a limit in px: positive past max, negative past min. It drives the shell stretch and the value's lean. */\n  const strain = useMotionValue(0);\n  const shellWidth = useMotionValue(160);\n  const shellScale = useTransform(() => 1 + Math.abs(strain.get()) / Math.max(1, shellWidth.get()));\n  const shellOrigin = useTransform(strain, (s) => (s >= 0 ? 0 : 1));\n  const lean = useTransform(strain, (s) => s * 0.5);\n  const fraction = showTrack ? Math.min(1, Math.max(0, (current - min) / (max - min))) : 0;\n  const fill = useMotionValue(fraction);\n  const cursorX = useMotionValue(0),\n    cursorY = useMotionValue(0);\n\n  useEffect(() => {\n    const node = shellRef.current;\n    if (!node) return;\n    const observer = new ResizeObserver(() => shellWidth.set(node.offsetWidth));\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, [shellWidth]);\n\n  // The fill follows a drag 1:1 and glides to typed or stepped values.\n  useEffect(() => {\n    if (reduced || drag.current?.moved) {\n      fill.jump(fraction);\n      return;\n    }\n    const controls = animate(fill, fraction, spring.smooth);\n    return () => controls.stop();\n  }, [fill, fraction, reduced]);\n\n  const emit = useCallback(\n    (next: number, source: ScrubInputSource, commit: boolean, nextUnit = latest.current.unit) => {\n      const changed = next !== latest.current.current;\n      if (changed) {\n        latest.current = { current: next, unit: nextUnit };\n        if (value === undefined) setInner(next);\n        onValueChange?.(next, { source, unit: nextUnit });\n      }\n      if (commit) onValueCommit?.(next, { source, unit: nextUnit });\n    },\n    [onValueChange, onValueCommit, value],\n  );\n\n  const constrain = (next: number) => {\n    const rounded = roundTo(next, places);\n    if (canWrap) {\n      const span = max - min;\n      return roundTo(((((rounded - min) % span) + span) % span) + min, places);\n    }\n    return Math.min(max, Math.max(min, rounded));\n  };\n\n  /** A velocity kick on the strain: the field leans toward the push and springs home. */\n  function bump(direction: number, kind: Transition = settle) {\n    if (reduced) return;\n    animate(strain, 0, { ...kind, velocity: direction * 260 } as Transition);\n  }\n\n  function flagInvalid() {\n    setInvalid(true);\n    window.clearTimeout(invalidTimer.current);\n    invalidTimer.current = window.setTimeout(() => setInvalid(false), 900);\n    bump(-1, shake);\n  }\n\n  function commitDraft(text: string | null = draft) {\n    setDraft(null);\n    if (text === null) return true;\n    if (text.trim() === format(current, places)) return true;\n    const parsed = evaluateScrubExpression(text, unitList);\n    if (!parsed) {\n      flagInvalid();\n      return false;\n    }\n    let nextUnit = latest.current.unit;\n    if (parsed.unit && parsed.unit !== nextUnit) {\n      nextUnit = parsed.unit;\n      if (unitProp === undefined) setInnerUnit(nextUnit);\n      onUnitChange?.(nextUnit);\n    }\n    const next = constrain(parsed.value);\n    if (!canWrap && (parsed.value > max || parsed.value < min)) bump(parsed.value > max ? 1 : -1);\n    emit(next, \"type\", true, nextUnit);\n    return true;\n  }\n\n  function stepBy(direction: 1 | -1, event: { shiftKey: boolean; altKey: boolean }) {\n    const amount = event.shiftKey ? coarse : event.altKey ? fine : step;\n    const base = latest.current.current;\n    if (!canWrap && ((direction > 0 && base >= max) || (direction < 0 && base <= min))) {\n      bump(direction);\n      return;\n    }\n    // Coarse steps land on the coarse grid, so 13 with Shift goes to 20, not 23.\n    const snapped = event.shiftKey\n      ? (direction > 0 ? Math.floor(base / amount + 1e-9) + 1 : Math.ceil(base / amount - 1e-9) - 1) * amount\n      : base + direction * amount;\n    emit(constrain(snapped), \"key\", true);\n  }\n\n  // ---- Scrubbing ----------------------------------------------------------------------------------------------------------------\n  const endScrub = useCallback(\n    (cancel: boolean) => {\n      const state = drag.current;\n      drag.current = null;\n      if (!state) return;\n      document.documentElement.style.removeProperty(\"cursor\");\n      if (document.pointerLockElement === handleRef.current) document.exitPointerLock?.();\n      setScrubbing(null);\n      setModifier(null);\n      if (!state.moved) return;\n      suppressClick.current = true;\n      if (cancel) emit(state.start, \"scrub\", true);\n      else onValueCommit?.(latest.current.current, { source: \"scrub\", unit: latest.current.unit });\n      if (reduced) strain.jump(0);\n      else animate(strain, 0, settle);\n    },\n    [emit, onValueCommit, reduced, strain],\n  );\n\n  useEffect(() => {\n    if (!scrubbing) return;\n    const onKey = (event: KeyboardEvent) => {\n      if (event.key === \"Escape\") {\n        event.preventDefault();\n        endScrub(true);\n        return;\n      }\n      if (event.key === \"Shift\" || event.key === \"Alt\")\n        setModifier(event.shiftKey ? \"coarse\" : event.altKey ? \"fine\" : null);\n    };\n    const onLock = () => {\n      if (drag.current?.locked && document.pointerLockElement !== handleRef.current) endScrub(false);\n    };\n    window.addEventListener(\"keydown\", onKey);\n    window.addEventListener(\"keyup\", onKey);\n    document.addEventListener(\"pointerlockchange\", onLock);\n    return () => {\n      window.removeEventListener(\"keydown\", onKey);\n      window.removeEventListener(\"keyup\", onKey);\n      document.removeEventListener(\"pointerlockchange\", onLock);\n    };\n  }, [endScrub, scrubbing]);\n\n  useEffect(\n    () => () => {\n      document.documentElement.style.removeProperty(\"cursor\");\n    },\n    [],\n  );\n\n  function onHandleDown(event: ReactPointerEvent<HTMLLabelElement>) {\n    if (disabled || drag.current || (event.pointerType === \"mouse\" && event.button !== 0)) return;\n    event.preventDefault();\n    if (draft !== null) commitDraft();\n    event.currentTarget.setPointerCapture(event.pointerId);\n    drag.current = {\n      id: event.pointerId,\n      lastX: event.clientX,\n      travel: 0,\n      raw: latest.current.current,\n      start: latest.current.current,\n      moved: false,\n      locked: false,\n      mouse: event.pointerType === \"mouse\",\n    };\n    cursorX.jump(event.clientX);\n    cursorY.jump(event.clientY);\n  }\n\n  function onHandleMove(event: ReactPointerEvent<HTMLLabelElement>) {\n    const state = drag.current;\n    if (!state || event.pointerId !== state.id) return;\n    const locked = document.pointerLockElement === handleRef.current;\n    const dx = locked ? event.movementX : event.clientX - state.lastX;\n    state.lastX = event.clientX;\n    state.travel += dx;\n    if (!state.moved) {\n      if (Math.abs(state.travel) < SLOP) return;\n      state.moved = true;\n      inputRef.current?.blur();\n      document.documentElement.style.cursor = \"ew-resize\";\n      if (pointerLock && state.mouse && handleRef.current?.requestPointerLock) {\n        try {\n          const request = handleRef.current.requestPointerLock() as unknown;\n          if (request instanceof Promise) request.catch(() => {});\n        } catch {\n          /* Pointer lock is optional. */\n        }\n      }\n      setScrubbing({ locked: false, mouse: state.mouse });\n    }\n    if (locked && !state.locked) {\n      state.locked = true;\n      setScrubbing({ locked: true, mouse: true });\n    }\n\n    // A virtual cursor stands in for the locked pointer and wraps at the window edges, the way long scrubs do in design tools.\n    if (locked) {\n      const width = window.innerWidth;\n      cursorX.jump((((cursorX.get() + dx) % width) + width) % width);\n    } else {\n      cursorX.jump(event.clientX);\n      cursorY.jump(event.clientY);\n    }\n\n    const amount = event.shiftKey ? coarse : event.altKey ? fine : step;\n    const mode = event.shiftKey ? \"coarse\" : event.altKey ? \"fine\" : null;\n    if (mode !== modifier) setModifier(mode);\n    state.raw += (dx / pixelsPerStep) * amount;\n\n    if (!canWrap) {\n      const perPx = amount / pixelsPerStep;\n      const over = state.raw > max ? (state.raw - max) / perPx : state.raw < min ? (state.raw - min) / perPx : 0;\n      // Cap the stored overshoot so reversing starts moving the value again after a short relax, not a long walk back.\n      if (Math.abs(over) > OVERSHOOT_CAP) state.raw = (over > 0 ? max : min) + Math.sign(over) * OVERSHOOT_CAP * perPx;\n      strain.jump(reduced ? 0 : rubber(Math.max(-OVERSHOOT_CAP, Math.min(OVERSHOOT_CAP, over))));\n    }\n    const snapped = Math.round(state.raw / amount) * amount;\n    const next = constrain(snapped);\n    if (next !== latest.current.current) emit(next, \"scrub\", false);\n  }\n\n  function onHandleUp(event: ReactPointerEvent<HTMLLabelElement>) {\n    const state = drag.current;\n    if (!state || event.pointerId !== state.id) return;\n    endScrub(false);\n  }\n\n  function onHandleClick(event: ReactMouseEvent<HTMLLabelElement>) {\n    if (suppressClick.current) {\n      suppressClick.current = false;\n      event.preventDefault();\n      return;\n    }\n    if (disabled) return;\n    event.preventDefault();\n    inputRef.current?.focus();\n    inputRef.current?.select();\n  }\n\n  function onHandleDoubleClick() {\n    if (disabled || resetValue === undefined) return;\n    emit(constrain(resetValue), \"reset\", true);\n  }\n\n  // ---- Typing -------------------------------------------------------------------------------------------------------------------\n  function onInputKeyDown(event: ReactKeyboardEvent<HTMLInputElement>) {\n    onKeyDown?.(event);\n    if (event.defaultPrevented) return;\n    if (event.key === \"ArrowUp\" || event.key === \"ArrowDown\") {\n      event.preventDefault();\n      if (draft !== null && !commitDraft()) return;\n      stepBy(event.key === \"ArrowUp\" ? 1 : -1, event);\n      requestAnimationFrame(() => inputRef.current?.select());\n      return;\n    }\n    if (event.key === \"Enter\") {\n      event.preventDefault();\n      commitDraft();\n      requestAnimationFrame(() => inputRef.current?.select());\n      return;\n    }\n    if (event.key === \"Escape\" && draft !== null) {\n      event.preventDefault();\n      event.stopPropagation();\n      setDraft(null);\n      requestAnimationFrame(() => inputRef.current?.select());\n    }\n  }\n\n  function onInputFocus(event: FocusEvent<HTMLInputElement>) {\n    onFocus?.(event);\n    event.currentTarget.select();\n  }\n  function onInputBlur(event: FocusEvent<HTMLInputElement>) {\n    onBlur?.(event);\n    commitDraft();\n  }\n\n  function cycleUnit() {\n    if (unitList.length < 2) return;\n    const next = unitList[(unitList.indexOf(unit) + 1) % unitList.length]!;\n    if (unitProp === undefined) setInnerUnit(next);\n    onUnitChange?.(next);\n    latest.current = { ...latest.current, unit: next };\n  }\n\n  const text = draft ?? format(current, places);\n  const valueText = `${format(current, places)}${unit ? ` ${unit === \"%\" ? \"percent\" : unit === \"deg\" || unit === \"°\" ? \"degrees\" : unit}` : \"\"}`;\n  const multiplier =\n    modifier === \"coarse\" ? `×${format(coarse / step, 2)}` : modifier === \"fine\" ? `×${format(fine / step, 3)}` : null;\n  const labelIsText = typeof label === \"string\" || typeof label === \"number\";\n\n  // One row: a scrub handle, the number, and its unit. The shell is a separate layer behind them so it can strain past a limit without\n  // stretching the text. Set --scrub-input-width to size the field; it defaults to filling its column. Hover and focus speak through\n  // the shell's edge; a scrub darkens the label so the grab reads as held.\n  return (\n    <div\n      className={cn(\n        \"group/scrub relative isolate grid h-(--scrub-height) w-[var(--scrub-input-width,100%)] min-w-0 grid-cols-[auto_minmax(0,1fr)] items-stretch font-body text-sm/[inherit] tracking-body text-ink [--scrub-fill:color-mix(in_oklab,var(--ink)_6%,transparent)] [--scrub-height:var(--control-height-sm)] [--scrub-radius:calc(var(--radius-control)_-_6px)] data-disabled:opacity-50 data-scrubbing:[--scrub-fill:color-mix(in_oklab,var(--ink)_10%,transparent)] data-[size=sm]:text-xs/[inherit] data-[size=sm]:[--scrub-height:30px] data-[size=sm]:[--scrub-radius:9px]\",\n        className,\n      )}\n      data-size={size}\n      data-disabled={disabled ? \"\" : undefined}\n      data-scrubbing={scrubbing ? \"\" : undefined}\n      data-invalid={invalid ? \"\" : undefined}\n    >\n      <motion.div\n        ref={shellRef}\n        className={cn(\n          \"absolute inset-0 -z-1 overflow-hidden rounded-(--scrub-radius) border border-transparent bg-hover transition-[background-color,border-color] duration-fast ease-standard will-change-transform group-focus-within/scrub:border-line-strong group-focus-within/scrub:bg-surface group-data-scrubbing/scrub:border-line-strong group-data-invalid/scrub:border-red motion-reduce:duration-instant\",\n          !disabled && \"group-hover/scrub:border-line group-hover/scrub:group-focus-within/scrub:border-line-strong\",\n        )}\n        aria-hidden=\"true\"\n        style={{ scaleX: shellScale, originX: shellOrigin }}\n      >\n        {showTrack && (\n          <motion.span\n            className=\"pointer-events-none absolute inset-0 origin-left bg-(--scrub-fill)\"\n            style={{ scaleX: fill }}\n          />\n        )}\n      </motion.div>\n      {/* oxlint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions -- the label is the scrub handle for pointers; keyboard users type in and step the input it labels */}\n      <label\n        ref={handleRef}\n        htmlFor={id}\n        className=\"flex min-w-[30px] cursor-ew-resize touch-pan-y items-center justify-center pr-1 pl-2.5 text-ink-3 transition-[color] duration-fast ease-standard select-none [-webkit-tap-highlight-color:transparent] hover:text-ink data-text:font-medium group-data-disabled/scrub:cursor-not-allowed group-data-scrubbing/scrub:text-ink motion-reduce:duration-instant [&_svg]:size-3.5 [&_svg]:flex-none\"\n        data-text={labelIsText ? \"\" : undefined}\n        onPointerDown={onHandleDown}\n        onPointerMove={onHandleMove}\n        onPointerUp={onHandleUp}\n        onPointerCancel={onHandleUp}\n        onClick={onHandleClick}\n        onDoubleClick={onHandleDoubleClick}\n      >\n        {label}\n      </label>\n      <motion.div className=\"flex min-w-0 items-center gap-0.5 pr-2.5\" style={{ x: lean }}>\n        <input\n          {...rest}\n          ref={inputRef}\n          id={id}\n          className=\"m-0 h-full w-full min-w-0 flex-1 border-0 bg-transparent py-0 pr-0 pl-1 text-ellipsis tabular-nums outline-none selection:bg-[color-mix(in_oklab,var(--ink)_16%,transparent)] focus-visible:outline-none disabled:cursor-not-allowed\"\n          type=\"text\"\n          inputMode=\"decimal\"\n          autoComplete=\"off\"\n          spellCheck={false}\n          role=\"spinbutton\"\n          aria-label={ariaLabel}\n          aria-valuenow={current}\n          aria-valuemin={Number.isFinite(min) ? min : undefined}\n          aria-valuemax={Number.isFinite(max) ? max : undefined}\n          aria-valuetext={valueText}\n          aria-invalid={invalid || undefined}\n          disabled={disabled}\n          value={text}\n          onChange={(event) => setDraft(event.target.value)}\n          onFocus={onInputFocus}\n          onBlur={onInputBlur}\n          onKeyDown={onInputKeyDown}\n        />\n        {unit &&\n          (unitList.length > 1 ? (\n            <button\n              type=\"button\"\n              className={cn(unitBase, unitCycle)}\n              data-cycle=\"\"\n              disabled={disabled}\n              onClick={cycleUnit}\n              aria-label={`Unit: ${unit}. Change unit`}\n            >\n              <AnimatePresence mode=\"popLayout\" initial={false}>\n                <motion.span\n                  key={unit}\n                  className=\"block\"\n                  initial={reduced ? { opacity: 0 } : { opacity: 0, y: 6, filter: \"blur(2px)\" }}\n                  animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n                  exit={\n                    reduced\n                      ? { opacity: 0 }\n                      : { opacity: 0, y: -6, filter: \"blur(2px)\", transition: { duration: duration.instant } }\n                  }\n                  transition={reduced ? { duration: duration.instant } : spring.snappy}\n                >\n                  {unit}\n                </motion.span>\n              </AnimatePresence>\n            </button>\n          ) : (\n            <span className={unitBase} aria-hidden=\"true\">\n              {unit}\n            </span>\n          ))}\n      </motion.div>\n      <AnimatePresence>\n        {multiplier && !scrubbing?.locked && (\n          <motion.span\n            key=\"tag\"\n            className=\"pointer-events-none absolute bottom-[calc(100%_+_6px)] left-1.5 z-2 rounded-full bg-ink px-[7px] py-0.5 text-xs/4 text-page tabular-nums\"\n            aria-hidden=\"true\"\n            initial={reduced ? { opacity: 0 } : { opacity: 0, y: 4, scale: 0.96 }}\n            animate={{ opacity: 1, y: 0, scale: 1 }}\n            exit={{ opacity: 0, transition: { duration: duration.instant } }}\n            transition={reduced ? { duration: duration.instant } : spring.snappy}\n          >\n            {multiplier}\n          </motion.span>\n        )}\n      </AnimatePresence>\n      {/* The stand-in cursor while the pointer is locked. It lives on the body, so it uses literal colors that read on any page. */}\n      {hydrated &&\n        scrubbing?.locked &&\n        createPortal(\n          <motion.div\n            className=\"pointer-events-none fixed top-0 left-0 z-[2147483647] -mt-2 -ml-[13px] flex items-center gap-1\"\n            style={{ x: cursorX, y: cursorY }}\n            aria-hidden=\"true\"\n            initial={{ opacity: 0 }}\n            animate={{ opacity: 1 }}\n            transition={{ duration: duration.instant, ease: [...ease.standard] }}\n          >\n            <svg\n              width=\"26\"\n              height=\"16\"\n              viewBox=\"0 0 26 16\"\n              className=\"block drop-shadow-[0_1px_2px_oklch(0%_0_0/.3)] [&_path]:fill-[oklch(15%_0_0)] [&_path]:stroke-white [&_path]:[stroke-linejoin:round] [&_path]:[stroke-width:1.4]\"\n            >\n              <path d=\"M1.5 8 L7.5 2.5 V6 H18.5 V2.5 L24.5 8 L18.5 13.5 V10 H7.5 V13.5 Z\" />\n            </svg>\n            <AnimatePresence>\n              {multiplier && (\n                <motion.span\n                  key=\"multiplier\"\n                  className=\"rounded-full bg-[oklch(15%_0_0)] px-1.5 py-px font-body text-xs/4 text-white tabular-nums\"\n                  initial={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.9, y: -2 }}\n                  animate={{ opacity: 1, scale: 1, y: 0 }}\n                  exit={{ opacity: 0, transition: { duration: duration.instant } }}\n                  transition={reduced ? { duration: duration.instant } : spring.snappy}\n                >\n                  {multiplier}\n                </motion.span>\n              )}\n            </AnimatePresence>\n          </motion.div>,\n          document.body,\n        )}\n    </div>\n  );\n});\n\nexport default ScrubInput;\n",
      "type": "registry:component",
      "target": "@components/iris/scrub-input/scrub-input.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/scrub-input",
  "meta": {
    "kind": "component",
    "group": "Inputs",
    "export": "ScrubInput",
    "tags": [
      "inputs",
      "new"
    ],
    "docs": "https://irisui.dev/components/scrub-input"
  }
}
