{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "number-field",
  "title": "Number Field",
  "description": "A number input on Base UI's NumberField with steppers that speed up while held, digits that roll on every step, Intl formatting with a true minus, min, max and step, and an optional scrub label.",
  "dependencies": [
    "@base-ui/react",
    "motion"
  ],
  "registryDependencies": [
    "https://eased.dev/r/eased-tokens.json",
    "https://eased.dev/r/eased-motion.json",
    "https://eased.dev/r/eased-field.json",
    "https://eased.dev/r/eased-number-roll.json"
  ],
  "files": [
    {
      "path": "src/registry/eased/number-field/number-field.tsx",
      "content": "\"use client\";\n\nimport { NumberField as BaseNumberField } from \"@base-ui/react/number-field\";\nimport { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type ComponentProps, type PointerEvent, type ReactNode } from \"react\";\nimport { InputGroup, InputGroupAddon, controlClass, type ControlSize } from \"../_primitives/field/field\";\nimport { NumberRoll } from \"../_primitives/number-roll/number-roll\";\nimport { minusFormatter } from \"../lib/format\";\nimport { pressIcon } from \"../lib/press\";\n\ntype RootProps = ComponentProps<typeof BaseNumberField.Root>;\n\nexport type NumberFieldProps = Omit<RootProps, \"className\" | \"render\" | \"value\" | \"defaultValue\" | \"onValueChange\" | \"children\" | \"step\"> & {\n  /** Controlled value; `null` is empty. */\n  value?: number | null;\n  /** Uncontrolled starting value. */\n  defaultValue?: number | null;\n  /** Fires on every change: typing, a stepper press, each repeat while held, arrows and scrubbing. */\n  onValueChange?: (value: number | null) => void;\n  /** Amount each press, arrow key or scrub tick moves. Default 1. Shift uses `largeStep`, Alt `smallStep`. */\n  step?: number;\n  /** A visible label above the field. Leave it out inside a `Field` with its own label. */\n  label?: ReactNode;\n  /** Drag the label sideways to change the value (Base UI's scrub area). Needs `label`. Default false. */\n  scrub?: boolean;\n  /** Where the steppers sit: both at the end (default), or − before and + after a centred value. */\n  layout?: \"end\" | \"split\";\n  /** sm 32px, md 36px (default), lg 40px. */\n  size?: ControlSize;\n  /** Leading content inside the box, e.g. an icon. Not shown in the split layout. */\n  start?: ReactNode;\n  placeholder?: string;\n  /** Roll digits when the value changes from the steppers, arrows or a scrub. Default true. */\n  animated?: boolean;\n  /** Accessible names for the steppers. */\n  incrementLabel?: string;\n  decrementLabel?: string;\n  /** Classes for the root (label + box). */\n  className?: string;\n  /** Classes for the bordered box. */\n  groupClassName?: string;\n  \"aria-label\"?: string;\n};\n\n// Steppers sit 3px inside the box's border, so their radius is the box's minus that inset (R4).\nconst STEPPER: Record<ControlSize, { button: string; end: string; start: string }> = {\n  sm: {\n    button: \"size-6 rounded-control-2xs\",\n    end: \"-me-[7px]\",\n    start: \"-ms-[7px]\",\n  },\n  md: {\n    button: \"size-7 rounded-control-xs\",\n    end: \"-me-[9px]\",\n    start: \"-ms-[9px]\",\n  },\n  lg: {\n    button: \"size-8 rounded-control-xs\",\n    end: \"-me-[11px]\",\n    start: \"-ms-[11px]\",\n  },\n};\n\n// Press and hold: one step at once, then repeats after 400ms that speed up from 150ms to 35ms.\nconst HOLD_DELAY = 400;\nconst FIRST_TICK = 150;\nconst FASTEST_TICK = 35;\nconst ACCELERATION = 0.86;\n\nconst decimals = (n: number) => {\n  const s = String(n);\n  if (s.includes(\"e-\")) return Number(s.split(\"e-\")[1]);\n  return s.split(\".\")[1]?.length ?? 0;\n};\n\n/**\n * A number input on Base UI's NumberField: typed entry parses in the field's locale, arrows\n * and PageUp/PageDown step, and the value is clamped to `min`/`max`. Its steppers repeat while\n * held and speed up the longer you hold. Outside of typing, the shown value is a NumberRoll, so\n * each step rolls its digits in the direction of change, and negatives use the true minus.\n */\nexport function NumberField({\n  value: valueProp,\n  defaultValue = null,\n  onValueChange,\n  step = 1,\n  smallStep = step / 10,\n  largeStep = step * 10,\n  min,\n  max,\n  format,\n  locale,\n  label,\n  scrub = false,\n  layout = \"end\",\n  size = \"md\",\n  start,\n  placeholder,\n  animated = true,\n  incrementLabel = \"Increase\",\n  decrementLabel = \"Decrease\",\n  disabled,\n  readOnly,\n  id: idProp,\n  className = \"\",\n  groupClassName = \"\",\n  \"aria-label\": ariaLabel,\n  ...props\n}: NumberFieldProps) {\n  const autoId = useId();\n  const id = idProp ?? autoId;\n  const [uncontrolled, setUncontrolled] = useState<number | null>(defaultValue);\n  const value = valueProp !== undefined ? valueProp : uncontrolled;\n  // The latest value for the hold loop, which steps from it between renders.\n  const valueRef = useRef(value);\n  useLayoutEffect(() => {\n    valueRef.current = value;\n  }, [value]);\n\n  // The roll stands in for the input's text unless someone is typing in it.\n  const [focused, setFocused] = useState(false);\n  const [typing, setTyping] = useState(false);\n  const [scrubbing, setScrubbing] = useState(false);\n  const showRoll = value !== null && (!focused || !typing);\n\n  const set = useCallback(\n    (next: number | null) => {\n      if (valueProp === undefined) setUncontrolled(next);\n      valueRef.current = next;\n      onValueChange?.(next);\n    },\n    [valueProp, onValueChange],\n  );\n\n  const lo = min ?? -Infinity;\n  const hi = max ?? Infinity;\n  const places = Math.max(decimals(step), decimals(smallStep), decimals(largeStep));\n\n  /** One step from the current value; false once a bound stops it. */\n  const stepBy = useCallback(\n    (amount: number) => {\n      const clamp = (n: number) => Math.min(hi, Math.max(lo, n));\n      const current = valueRef.current;\n      // From empty, the first press lands on 0 (or the nearest bound), as Base UI's arrows do.\n      const next = current === null ? clamp(0) : clamp(Number((current + amount).toFixed(places)));\n      if (next === valueRef.current) return false;\n      set(next);\n      setTyping(false);\n      return next !== lo && next !== hi;\n    },\n    [lo, hi, places, set],\n  );\n\n  const roll = useMemo(() => minusFormatter(locale, format ?? {}), [locale, format]);\n  const atMin = value !== null && value <= lo;\n  const atMax = value !== null && value >= hi;\n  const inert = disabled || readOnly;\n\n  const s = STEPPER[size];\n  const decrement = (\n    <Stepper\n      dir={-1}\n      label={decrementLabel}\n      disabled={inert || atMin}\n      className={s.button}\n      stepBy={stepBy}\n      step={step}\n      smallStep={smallStep}\n      largeStep={largeStep}\n      controls={id}\n    >\n      <MinusIcon />\n    </Stepper>\n  );\n  const increment = (\n    <Stepper\n      dir={1}\n      label={incrementLabel}\n      disabled={inert || atMax}\n      className={s.button}\n      stepBy={stepBy}\n      step={step}\n      smallStep={smallStep}\n      largeStep={largeStep}\n      controls={id}\n    >\n      <PlusIcon />\n    </Stepper>\n  );\n  const split = layout === \"split\";\n\n  return (\n    <BaseNumberField.Root\n      {...props}\n      id={id}\n      value={value}\n      onValueChange={(next, details) => {\n        set(next);\n        const r = details.reason;\n        setTyping(r === \"input-change\" || r === \"input-clear\" || r === \"input-paste\");\n        setScrubbing(r === \"scrub\");\n      }}\n      onValueCommitted={(next, details) => {\n        setScrubbing(false);\n        props.onValueCommitted?.(next, details);\n      }}\n      step={step}\n      smallStep={smallStep}\n      largeStep={largeStep}\n      min={min}\n      max={max}\n      format={format}\n      locale={locale}\n      disabled={disabled}\n      readOnly={readOnly}\n      className={`group/number flex min-w-0 flex-col gap-1.5 ${className}`}\n    >\n      {label !== undefined &&\n        (scrub ? (\n          <BaseNumberField.ScrubArea className=\"flex w-fit cursor-ew-resize touch-none select-none data-disabled:cursor-not-allowed\" pixelSensitivity={4}>\n            <label\n              htmlFor={id}\n              className=\"cursor-ew-resize text-ui font-medium text-text decoration-text-muted decoration-dotted underline-offset-4 group-data-disabled/number:cursor-not-allowed group-data-disabled/number:text-text-disabled hover-fine:underline\"\n            >\n              {label}\n            </label>\n            {/* Shown in place of the pointer while it is locked during a scrub. */}\n            <BaseNumberField.ScrubAreaCursor className=\"text-text drop-shadow-[0_1px_1px_rgb(0_0_0/0.25)]\">\n              <ScrubCursorIcon />\n            </BaseNumberField.ScrubAreaCursor>\n          </BaseNumberField.ScrubArea>\n        ) : (\n          <label htmlFor={id} className=\"w-fit cursor-default text-ui font-medium text-text group-data-disabled/number:text-text-disabled\">\n            {label}\n          </label>\n        ))}\n      <InputGroup size={size} className={groupClassName}>\n        {/* `contents`, so the group's parts lay out in the box's row while keeping role=\"group\". */}\n        <BaseNumberField.Group className=\"contents\">\n          {split ? <span className={`flex shrink-0 ${s.start}`}>{decrement}</span> : start !== undefined && <InputGroupAddon>{start}</InputGroupAddon>}\n          <span className=\"relative flex min-w-0 flex-1 self-stretch\">\n            <BaseNumberField.Input\n              placeholder={placeholder}\n              aria-label={ariaLabel}\n              onFocus={() => setFocused(true)}\n              onBlur={() => {\n                setFocused(false);\n                setTyping(false);\n              }}\n              className={`${controlClass} tabular-nums ${split ? \"text-center\" : \"\"} ${showRoll ? \"text-transparent! caret-transparent selection:bg-transparent\" : \"\"}`}\n            />\n            {showRoll && (\n              <span\n                aria-hidden\n                className={`pointer-events-none absolute inset-0 flex items-center overflow-hidden text-text group-data-disabled/number:text-text-disabled pointer-coarse:text-ui-md ${split ? \"justify-center\" : \"\"}`}\n              >\n                <NumberRoll value={value} formatter={roll} animated={animated && !scrubbing} aria-hidden />\n              </span>\n            )}\n          </span>\n          <span className={`flex shrink-0 items-center gap-0.5 ${s.end}`}>\n            {!split && decrement}\n            {increment}\n          </span>\n        </BaseNumberField.Group>\n      </InputGroup>\n    </BaseNumberField.Root>\n  );\n}\n\ntype StepperProps = {\n  dir: 1 | -1;\n  label: string;\n  disabled?: boolean;\n  className: string;\n  step: number;\n  smallStep: number;\n  largeStep: number;\n  controls: string;\n  stepBy: (amount: number) => boolean;\n  children: ReactNode;\n};\n\n/**\n * A stepper that repeats while held, accelerating. It never takes focus, so the caret stays in\n * the input; keyboard users step with the arrow keys there instead.\n */\nfunction Stepper({ dir, label, disabled, className, step, smallStep, largeStep, controls, stepBy, children }: StepperProps) {\n  const timer = useRef<ReturnType<typeof setTimeout> | null>(null);\n  const stop = useCallback(() => {\n    if (timer.current) clearTimeout(timer.current);\n    timer.current = null;\n  }, []);\n  useEffect(() => stop, [stop]);\n  useEffect(() => {\n    if (disabled) stop();\n  }, [disabled, stop]);\n\n  const begin = (e: PointerEvent<HTMLButtonElement>) => {\n    if (e.button !== 0 || disabled) return;\n    // Keep focus where it is (the input, or nowhere on touch, so no keyboard opens).\n    e.preventDefault();\n    const amount = dir * (e.shiftKey ? largeStep : e.altKey ? smallStep : step);\n    if (!stepBy(amount)) return;\n    let tick = FIRST_TICK;\n    const repeat = () => {\n      if (!stepBy(amount)) return stop();\n      tick = Math.max(FASTEST_TICK, tick * ACCELERATION);\n      timer.current = setTimeout(repeat, tick);\n    };\n    timer.current = setTimeout(repeat, HOLD_DELAY);\n    const end = () => {\n      stop();\n      window.removeEventListener(\"pointerup\", end);\n      window.removeEventListener(\"pointercancel\", end);\n      window.removeEventListener(\"blur\", end);\n    };\n    window.addEventListener(\"pointerup\", end);\n    window.addEventListener(\"pointercancel\", end);\n    window.addEventListener(\"blur\", end);\n  };\n\n  return (\n    <button\n      type=\"button\"\n      tabIndex={-1}\n      aria-label={label}\n      aria-controls={controls}\n      disabled={disabled}\n      onPointerDown={begin}\n      onContextMenu={(e) => e.preventDefault()}\n      className={`inline-flex shrink-0 cursor-pointer touch-manipulation items-center justify-center text-text-muted select-none hover-fine:not-disabled:bg-surface-hover hover-fine:not-disabled:text-text active:not-disabled:bg-surface-active active:not-disabled:text-text disabled:cursor-not-allowed disabled:text-text-disabled ${pressIcon} ${className}`}\n    >\n      {children}\n    </button>\n  );\n}\n\nfunction MinusIcon() {\n  return (\n    <svg viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden className=\"size-4\">\n      <path d=\"M3.75 8h8.5\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" />\n    </svg>\n  );\n}\n\nfunction PlusIcon() {\n  return (\n    <svg viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden className=\"size-4\">\n      <path d=\"M8 3.75v8.5M3.75 8h8.5\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" />\n    </svg>\n  );\n}\n\nfunction ScrubCursorIcon() {\n  return (\n    <svg viewBox=\"0 0 24 16\" width=\"24\" height=\"16\" fill=\"none\" aria-hidden>\n      <path d=\"M8 3.5 3.5 8 8 12.5M16 3.5 20.5 8 16 12.5M4 8h16\" stroke=\"currentColor\" strokeWidth=\"1.75\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n    </svg>\n  );\n}\n",
      "type": "registry:ui",
      "target": "@components/eased/number-field/number-field.tsx"
    }
  ],
  "docs": "Import NumberField from components/eased/number-field/number-field; give it a label, or wrap it in Field. The Eased tokens were merged into your global CSS; nothing to import.",
  "type": "registry:ui"
}