{
  "$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://mcp.xzero.co.uk/r/xzero-motion-tokens.json"
  ],
  "files": [
    {
      "path": "registry/components/scrub-input/scrub-input.tsx",
      "type": "registry:component",
      "target": "@components/xzero/scrub-input/scrub-input.tsx",
      "content": "\"use client\";\n\nimport { forwardRef, useCallback, useEffect, useId, useImperativeHandle, useRef, useState, useSyncExternalStore } from \"react\";\nimport type { FocusEvent, InputHTMLAttributes, KeyboardEvent as ReactKeyboardEvent, MouseEvent as ReactMouseEvent, PointerEvent as ReactPointerEvent, ReactNode } 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 styles from \"./scrub-input.module.css\";\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<InputHTMLAttributes<HTMLInputElement>, \"value\" | \"defaultValue\" | \"onChange\" | \"type\" | \"size\" | \"min\" | \"max\" | \"step\" | \"prefix\"> {\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, STRAIN = 12, OVERSHOOT_CAP = 90;\nconst physical = (visualDuration: number, bounce: number): Transition => {\n  const root = 2 * Math.PI / (visualDuration * 1.2);\n  return { type: \"spring\", stiffness: root * root, damping: 2 * (1 - bounce) * root, mass: 1, restDelta: .01, restSpeed: .5 };\n};\nconst settle = physical(.34, 0), shake = physical(.3, 0);\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) => { const f = 10 ** places; return Math.round(value * f) / f; };\n/** iOS style resistance: each extra pixel gives less, and the strain never exceeds `limit`. */\nconst rubber = (distance: number, limit = STRAIN) => Math.sign(distance) * (1 - 1 / (Math.abs(distance) * .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.trim().replace(/,/g, \".\").replace(/[×x](?=\\s*[\\d(.])/gi, \"*\").replace(/÷/g, \"/\").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, unit: string | undefined, 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 === \"(\") { const inner = sum(); if (tokens![index++] !== \")\") bad = true; return inner; }\n    if (token === \"-\") return -primary();\n    if (token === \"+\") return primary();\n    if (token === undefined || !/^\\d*\\.?\\d+/.test(token)) { bad = true; return 0; }\n    const number = Number(token);\n    const next = peek();\n    if (next && /^[a-z%°]+$/i.test(next)) {\n      index++;\n      const word = next.toLowerCase(), alias = word === \"°\" ? \"deg\" : word === \"deg\" ? \"°\" : word;\n      const found = known.includes(word) ? known.indexOf(word) : known.indexOf(alias);\n      if (found < 0) bad = true; else unit = units[found];\n    }\n    return number;\n  }\n  function product(): number {\n    let left = primary();\n    while (peek() === \"*\" || peek() === \"/\") { const op = tokens![index++]; const right = primary(); left = op === \"*\" ? left * right : left / right; }\n    return left;\n  }\n  function sum(): number {\n    let left = product();\n    while (peek() === \"+\" || peek() === \"-\") { const op = tokens![index++]; const right = product(); left = op === \"+\" ? left + right : left - right; }\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() { return useSyncExternalStore(subscribe, () => true, () => false); }\n\ntype Drag = { id: number; lastX: number; travel: number; raw: number; start: number; moved: boolean; locked: boolean; mouse: boolean };\n\nexport const ScrubInput = forwardRef<HTMLInputElement, ScrubInputProps>(function ScrubInput({\n  label, value, defaultValue = 0, onValueChange, onValueCommit, min = -Infinity, max = Infinity, step = 1, fineStep, coarseStep, precision,\n  pixelsPerStep = 2, wrap = false, unit: unitProp, defaultUnit, units, onUnitChange, resetValue, track, size = \"md\", pointerLock = true,\n  className, disabled, id: idProp, onFocus, onBlur, onKeyDown, \"aria-label\": ariaLabel, ...rest\n}, forwarded) {\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, 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(() => { latest.current = { current, unit }; }, [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 * .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), 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) { fill.jump(fraction); return; }\n    const controls = animate(fill, fraction, spring.smooth);\n    return () => controls.stop();\n  }, [fill, fraction, reduced]);\n\n  const emit = useCallback((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  }, [onValueChange, onValueCommit, value]);\n\n  const constrain = (next: number) => {\n    const rounded = roundTo(next, places);\n    if (canWrap) { const span = max - min; return roundTo(((rounded - min) % span + span) % span + min, places); }\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) { flagInvalid(); return false; }\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))) { bump(direction); return; }\n    // Coarse steps land on the coarse grid, so 13 with Shift goes to 20, not 23.\n    const snapped = event.shiftKey ? (direction > 0 ? Math.floor(base / amount + 1e-9) + 1 : Math.ceil(base / amount - 1e-9) - 1) * amount : base + direction * amount;\n    emit(constrain(snapped), \"key\", true);\n  }\n\n  // ---- Scrubbing ----------------------------------------------------------------------------------------------------------------\n  const endScrub = useCallback((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); else animate(strain, 0, settle);\n  }, [emit, onValueCommit, reduced, strain]);\n\n  useEffect(() => {\n    if (!scrubbing) return;\n    const onKey = (event: KeyboardEvent) => {\n      if (event.key === \"Escape\") { event.preventDefault(); endScrub(true); return; }\n      if (event.key === \"Shift\" || event.key === \"Alt\") setModifier(event.shiftKey ? \"coarse\" : event.altKey ? \"fine\" : null);\n    };\n    const onLock = () => { if (drag.current?.locked && document.pointerLockElement !== handleRef.current) endScrub(false); };\n    window.addEventListener(\"keydown\", onKey);\n    window.addEventListener(\"keyup\", onKey);\n    document.addEventListener(\"pointerlockchange\", onLock);\n    return () => { window.removeEventListener(\"keydown\", onKey); window.removeEventListener(\"keyup\", onKey); document.removeEventListener(\"pointerlockchange\", onLock); };\n  }, [endScrub, scrubbing]);\n\n  useEffect(() => () => { document.documentElement.style.removeProperty(\"cursor\"); }, []);\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 = { id: event.pointerId, lastX: event.clientX, travel: 0, raw: latest.current.current, start: latest.current.current, moved: false, locked: false, mouse: event.pointerType === \"mouse\" };\n    cursorX.jump(event.clientX); 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 { /* Pointer lock is optional. */ }\n      }\n      setScrubbing({ locked: false, mouse: state.mouse });\n    }\n    if (locked && !state.locked) { state.locked = true; setScrubbing({ locked: true, mouse: true }); }\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 { cursorX.jump(event.clientX); cursorY.jump(event.clientY); }\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) { suppressClick.current = false; event.preventDefault(); return; }\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 = modifier === \"coarse\" ? `×${format(coarse / step, 2)}` : modifier === \"fine\" ? `×${format(fine / step, 3)}` : null;\n  const labelIsText = typeof label === \"string\" || typeof label === \"number\";\n\n  return <div className={[styles.root, className].filter(Boolean).join(\" \")} data-size={size} data-disabled={disabled ? \"\" : undefined}\n    data-scrubbing={scrubbing ? \"\" : undefined} data-invalid={invalid ? \"\" : undefined}>\n    <motion.div ref={shellRef} className={styles.shell} aria-hidden=\"true\" style={{ scaleX: shellScale, originX: shellOrigin }}>\n      {showTrack && <motion.span className={styles.fill} style={{ scaleX: fill }} />}\n    </motion.div>\n    <label ref={handleRef} htmlFor={id} className={styles.handle} data-text={labelIsText ? \"\" : undefined}\n      onPointerDown={onHandleDown} onPointerMove={onHandleMove} onPointerUp={onHandleUp} onPointerCancel={onHandleUp}\n      onClick={onHandleClick} onDoubleClick={onHandleDoubleClick}>\n      {label}\n    </label>\n    <motion.div className={styles.body} style={{ x: lean }}>\n      <input\n        {...rest}\n        ref={inputRef}\n        id={id}\n        className={styles.input}\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 && (unitList.length > 1\n        ? <button type=\"button\" className={styles.unit} data-cycle=\"\" disabled={disabled} onClick={cycleUnit} aria-label={`Unit: ${unit}. Change unit`}>\n            <AnimatePresence mode=\"popLayout\" initial={false}>\n              <motion.span key={unit} className={styles.unitText}\n                initial={reduced ? { opacity: 0 } : { opacity: 0, y: 6, filter: \"blur(2px)\" }} animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n                exit={reduced ? { opacity: 0 } : { opacity: 0, y: -6, filter: \"blur(2px)\", transition: { duration: duration.instant } }}\n                transition={reduced ? { duration: duration.instant } : spring.snappy}>{unit}</motion.span>\n            </AnimatePresence>\n          </button>\n        : <span className={styles.unit} aria-hidden=\"true\">{unit}</span>)}\n    </motion.div>\n    <AnimatePresence>\n      {multiplier && !scrubbing?.locked && <motion.span key=\"tag\" className={styles.tag} aria-hidden=\"true\"\n        initial={reduced ? { opacity: 0 } : { opacity: 0, y: 4, scale: .96 }} animate={{ opacity: 1, y: 0, scale: 1 }}\n        exit={{ opacity: 0, transition: { duration: duration.instant } }} transition={reduced ? { duration: duration.instant } : spring.snappy}>\n        {multiplier}\n      </motion.span>}\n    </AnimatePresence>\n    {hydrated && scrubbing?.locked && createPortal(\n      <motion.div className={styles.cursor} style={{ x: cursorX, y: cursorY }} aria-hidden=\"true\"\n        initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: duration.instant, ease: [...ease.standard] }}>\n        <svg width=\"26\" height=\"16\" viewBox=\"0 0 26 16\" className={styles.cursorGlyph}>\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 && <motion.span key=\"multiplier\" className={styles.multiplier}\n            initial={reduced ? { opacity: 0 } : { opacity: 0, scale: .9, y: -2 }} animate={{ opacity: 1, scale: 1, y: 0 }}\n            exit={{ opacity: 0, transition: { duration: duration.instant } }} transition={reduced ? { duration: duration.instant } : spring.snappy}>\n            {multiplier}\n          </motion.span>}\n        </AnimatePresence>\n      </motion.div>, document.body)}\n  </div>;\n});\n\nexport default ScrubInput;\n"
    },
    {
      "path": "registry/components/scrub-input/scrub-input.module.css",
      "type": "registry:component",
      "target": "@components/xzero/scrub-input/scrub-input.module.css",
      "content": "/* 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\n   without stretching the text. Set --scrub-input-width to size the field; it defaults to filling its column. */\n.root {\n  --scrub-height: var(--control-height-sm);\n  --scrub-radius: calc(var(--radius-control) - 6px);\n  --scrub-fill: color-mix(in oklab, var(--foreground) 6%, transparent);\n  position: relative;\n  display: grid;\n  width: var(--scrub-input-width, 100%);\n  min-width: 0;\n  height: var(--scrub-height);\n  grid-template-columns: auto minmax(0, 1fr);\n  align-items: stretch;\n  color: var(--foreground);\n  font-family: var(--font-body);\n  font-size: var(--text-sm);\n  letter-spacing: var(--tracking-body);\n  isolation: isolate;\n}\n.root[data-size=\"sm\"] { --scrub-height: 30px; --scrub-radius: 9px; font-size: var(--text-xs); }\n\n.shell {\n  position: absolute;\n  z-index: -1;\n  inset: 0;\n  overflow: hidden;\n  border: 1px solid transparent;\n  border-radius: var(--scrub-radius);\n  background: var(--surface-muted);\n  transition: background-color var(--duration-fast) var(--ease-standard), border-color var(--duration-fast) var(--ease-standard);\n  will-change: transform;\n}\n/* How far the value sits between its limits, as a quiet wash from the left. */\n.fill { position: absolute; inset: 0; background: var(--scrub-fill); transform-origin: left center; pointer-events: none; }\n\n/* The label is the grab area. It reads as a label until the pointer finds it, then the cursor says it can be dragged. */\n.handle {\n  display: flex;\n  min-width: 30px;\n  align-items: center;\n  justify-content: center;\n  padding: 0 4px 0 10px;\n  color: var(--text-muted);\n  cursor: ew-resize;\n  touch-action: pan-y;\n  user-select: none;\n  -webkit-user-select: none;\n  -webkit-tap-highlight-color: transparent;\n  transition: color var(--duration-fast) var(--ease-standard);\n}\n.handle[data-text] { font-weight: 500; }\n.handle svg { width: 14px; height: 14px; flex: none; }\n\n.body { display: flex; min-width: 0; align-items: center; gap: 2px; padding-right: 10px; }\n.input {\n  width: 100%;\n  min-width: 0;\n  height: 100%;\n  flex: 1;\n  margin: 0;\n  padding: 0 0 0 4px;\n  border: 0;\n  background: transparent;\n  color: inherit;\n  font: inherit;\n  font-variant-numeric: tabular-nums;\n  letter-spacing: inherit;\n  outline: none;\n  text-overflow: ellipsis;\n}\n.input:focus-visible { outline: none; }\n.input::selection { background: color-mix(in oklab, var(--foreground) 16%, transparent); }\n.input:disabled { cursor: not-allowed; }\n\n.unit {\n  position: relative;\n  display: inline-flex;\n  flex: none;\n  align-items: center;\n  justify-content: center;\n  min-width: 18px;\n  height: 22px;\n  padding: 0 4px;\n  border: 0;\n  border-radius: 6px;\n  background: transparent;\n  color: var(--text-muted);\n  font: inherit;\n  font-size: var(--text-xs);\n  -webkit-tap-highlight-color: transparent;\n}\n.unit[data-cycle] { cursor: pointer; transition: background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard), transform var(--duration-fast) var(--ease-standard); }\n.unit[data-cycle]:active { transform: scale(.94); }\n.unitText { display: block; }\n\n.tag {\n  position: absolute;\n  z-index: 2;\n  bottom: calc(100% + 6px);\n  left: 6px;\n  padding: 2px 7px;\n  border-radius: var(--radius-pill);\n  background: var(--foreground);\n  color: var(--background);\n  font-size: var(--text-xs);\n  font-variant-numeric: tabular-nums;\n  line-height: 16px;\n  pointer-events: none;\n}\n\n/* States. Hover and focus speak through the shell's edge; a scrub darkens the label so the grab reads as held. */\n.root:focus-within .shell { border-color: var(--border-strong); background: var(--surface); }\n.root[data-scrubbing] .shell { border-color: var(--border-strong); }\n.root[data-scrubbing] .handle { color: var(--foreground); }\n.root[data-scrubbing] { --scrub-fill: color-mix(in oklab, var(--foreground) 10%, transparent); }\n.root[data-invalid] .shell { border-color: var(--danger); }\n.root[data-disabled] { opacity: .5; }\n.root[data-disabled] .handle { cursor: not-allowed; }\n@media (hover: hover) and (pointer: fine) {\n  .root:not([data-disabled]):hover .shell { border-color: var(--border); }\n  .root:not([data-disabled]):focus-within:hover .shell { border-color: var(--border-strong); }\n  .handle:hover { color: var(--foreground); }\n  .unit[data-cycle]:hover:not(:disabled) { background: color-mix(in oklab, var(--foreground) 7%, transparent); color: var(--foreground); }\n}\n\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.cursor { position: fixed; z-index: 2147483647; top: 0; left: 0; display: flex; align-items: center; gap: 4px; margin: -8px 0 0 -13px; pointer-events: none; }\n.cursorGlyph { display: block; filter: drop-shadow(0 1px 2px oklch(0% 0 0 / .3)); }\n.cursorGlyph path { fill: oklch(15% 0 0); stroke: white; stroke-width: 1.4; stroke-linejoin: round; }\n.multiplier { padding: 1px 6px; border-radius: 9999px; background: oklch(15% 0 0); color: white; font-family: var(--font-body, sans-serif); font-size: var(--text-xs, .75rem); font-variant-numeric: tabular-nums; line-height: 16px; }\n\n@media (prefers-reduced-motion: reduce) {\n  .shell, .handle, .unit[data-cycle] { transition-duration: var(--duration-instant); }\n  .unit[data-cycle]:active { transform: none; }\n}\n"
    }
  ],
  "docs": "Docs and live preview: https://ui.xzero.co.uk/components/scrub-input",
  "meta": {
    "tier": "free",
    "kind": "component",
    "tags": [
      "inputs",
      "new"
    ],
    "docs": "https://ui.xzero.co.uk/components/scrub-input",
    "markdown": "https://ui.xzero.co.uk/components/scrub-input/markdown"
  }
}