{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"number-field","type":"registry:ui","title":"Number field","description":"A numeric field with steppers, limits, a suffix and optional drag-to-scrub.","dependencies":["clsx","lucide-react","tailwind-merge"],"registryDependencies":["https://caliperui.site/r/styles.json"],"files":[{"path":"ui/label.tsx","type":"registry:ui","target":"components/ui/label.tsx","content":"import type { ComponentProps } from \"react\";\nimport { cn } from \"./lib/cn\";\n\nexport function Label({ className, ...props }: ComponentProps<\"label\">) {\n  return (\n    <label\n      data-slot=\"label\"\n      className={cn(\n        \"inline-block max-w-full text-sm leading-5 font-medium [overflow-wrap:anywhere]\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n"},{"path":"ui/lib/cn.ts","type":"registry:ui","target":"components/ui/lib/cn.ts","content":"import { clsx, type ClassValue } from \"clsx\";\nimport { extendTailwindMerge } from \"tailwind-merge\";\n\nconst twMerge = extendTailwindMerge({\n  extend: { theme: { radius: [\"control\", \"surface\", \"overlay\"], shadow: [\"overlay\"] } },\n});\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs));\n}\n"},{"path":"ui/lib/hold-step.ts","type":"registry:ui","target":"components/ui/lib/hold-step.ts","content":"\"use client\";\n\nimport { useEffect, useRef, type KeyboardEvent, type PointerEvent } from \"react\";\n\n/** Press once, then repeat after a pause and speed up while the pointer is held. */\nexport function useHoldStep(step: () => void) {\n  const stepRef = useRef(step);\n  useEffect(() => {\n    stepRef.current = step;\n  }, [step]);\n  const timers = useRef<number[]>([]);\n  const fromPointer = useRef(false);\n\n  function clear() {\n    for (const id of timers.current) window.clearTimeout(id);\n    timers.current = [];\n  }\n\n  useEffect(() => {\n    const onEnd = () => {\n      for (const id of timers.current) window.clearTimeout(id);\n      timers.current = [];\n    };\n    window.addEventListener(\"pointerup\", onEnd);\n    window.addEventListener(\"pointercancel\", onEnd);\n    return () => {\n      window.removeEventListener(\"pointerup\", onEnd);\n      window.removeEventListener(\"pointercancel\", onEnd);\n      onEnd();\n    };\n  }, []);\n\n  function arm(wait: number, gap: number) {\n    const id = window.setTimeout(() => {\n      stepRef.current();\n      arm(gap, Math.max(40, Math.round(gap * 0.8)));\n    }, wait);\n    timers.current.push(id);\n  }\n\n  return {\n    onPointerDown(event: PointerEvent<HTMLButtonElement>) {\n      if (event.button !== 0) return;\n      fromPointer.current = true;\n      try {\n        event.currentTarget.setPointerCapture(event.pointerId);\n      } catch {\n        /* The pointer can already be gone. */\n      }\n      clear();\n      stepRef.current();\n      arm(400, 150);\n    },\n    onPointerUp: clear,\n    onPointerCancel: clear,\n    onKeyUp(event: KeyboardEvent<HTMLButtonElement>) {\n      if (event.key === \" \" || event.key === \"Enter\") clear();\n    },\n    onClick() {\n      if (fromPointer.current) {\n        fromPointer.current = false;\n        return;\n      }\n      stepRef.current();\n    },\n  };\n}\n"},{"path":"ui/number-field.tsx","type":"registry:ui","target":"components/ui/number-field.tsx","content":"\"use client\";\n\nimport { Minus, Plus } from \"lucide-react\";\nimport { useId, useRef, useState, type KeyboardEvent } from \"react\";\nimport { useHoldStep } from \"./lib/hold-step\";\nimport { Label } from \"./label\";\nimport { cn } from \"./lib/cn\";\nimport { RollingValue } from \"./rolling-value\";\nimport \"./shared/chip-option.css\";\nimport \"./number-field.css\";\n\nfunction places(step: number) {\n  const text = String(step);\n  const dot = text.indexOf(\".\");\n  return dot < 0 ? 0 : text.length - dot - 1;\n}\n\nfunction clean(value: number, step: number) {\n  return Number(value.toFixed(places(step)));\n}\n\nfunction textOf(value: number, prefix: string, suffix: string) {\n  return `${prefix}${value}${suffix}`;\n}\n\nexport function NumberField({\n  label,\n  value,\n  defaultValue,\n  min = 0,\n  max = 100,\n  step = 1,\n  prefix = \"\",\n  suffix,\n  scrub = false,\n  disabled,\n  name,\n  id,\n  onValueChange,\n  className,\n}: {\n  label: string;\n  value?: number;\n  defaultValue?: number;\n  min?: number;\n  max?: number;\n  step?: number;\n  prefix?: string;\n  suffix?: string | ((value: number) => string);\n  scrub?: boolean;\n  disabled?: boolean;\n  name?: string;\n  id?: string;\n  onValueChange?: (value: number) => void;\n  className?: string;\n}) {\n  const generated = useId();\n  const fieldId = id ?? generated;\n  const lower = Math.min(min, max);\n  const upper = Math.max(min, max);\n  const initial = clean(Math.min(upper, Math.max(lower, defaultValue ?? lower)), step);\n  const [local, setLocal] = useState(initial);\n  const current = value ?? local;\n  const [draft, setDraft] = useState<string | null>(null);\n  const [focused, setFocused] = useState(false);\n  const [direction, setDirection] = useState<\"up\" | \"down\" | \"none\">(\"none\");\n  const [strain, setStrain] = useState<\"min\" | \"max\" | null>(null);\n  const [hint, setHint] = useState(\"\");\n  const hintTimer = useRef<number | null>(null);\n  const strainTimer = useRef<number | null>(null);\n  const scrubOrigin = useRef<{ x: number; value: number } | null>(null);\n  const suffixText = typeof suffix === \"function\" ? suffix(current) : (suffix ?? \"\");\n  const shown = draft ?? current.toFixed(places(step));\n  const parsed = Number(shown);\n  const outside = shown !== \"\" && Number.isFinite(parsed) && (parsed < lower || parsed > upper);\n\n  function note(edge: \"min\" | \"max\") {\n    const limit = edge === \"max\" ? upper : lower;\n    const end = typeof suffix === \"function\" ? suffix(limit) : (suffix ?? \"\");\n    setHint(edge === \"max\" ? `Maximum is ${textOf(limit, prefix, end)}` : `Minimum is ${textOf(limit, prefix, end)}`);\n    if (hintTimer.current) window.clearTimeout(hintTimer.current);\n    hintTimer.current = window.setTimeout(() => setHint(\"\"), 1500);\n  }\n\n  function push(edge: \"min\" | \"max\") {\n    if (!matchMedia(\"(prefers-reduced-motion: reduce)\").matches) {\n      setStrain(edge);\n      if (strainTimer.current) window.clearTimeout(strainTimer.current);\n      strainTimer.current = window.setTimeout(() => setStrain(null), 180);\n    }\n    note(edge);\n  }\n\n  function commit(next: number, way: \"up\" | \"down\" | \"none\") {\n    const clamped = clean(Math.min(upper, Math.max(lower, next)), step);\n    if (clamped === current) {\n      if (next < lower || next > upper) push(next > upper ? \"max\" : \"min\");\n      setDraft(null);\n      return clamped;\n    }\n    setDirection(way);\n    setDraft(null);\n    if (value == null) setLocal(clamped);\n    onValueChange?.(clamped);\n    return clamped;\n  }\n\n  function move(way: 1 | -1) {\n    const next = clean(current + way * step, step);\n    if (next > upper || next < lower || next === current) push(way > 0 ? \"max\" : \"min\");\n    else commit(next, way > 0 ? \"up\" : \"down\");\n  }\n\n  const decrease = useHoldStep(() => move(-1));\n  const increase = useHoldStep(() => move(1));\n\n  function onKeyDown(event: KeyboardEvent<HTMLInputElement>) {\n    if (event.key === \"ArrowUp\") {\n      event.preventDefault();\n      move(1);\n    } else if (event.key === \"ArrowDown\") {\n      event.preventDefault();\n      move(-1);\n    } else if (event.key === \"Home\") {\n      event.preventDefault();\n      commit(lower, \"down\");\n    } else if (event.key === \"End\") {\n      event.preventDefault();\n      commit(upper, \"up\");\n    } else if (event.key === \"Enter\") {\n      event.currentTarget.blur();\n    }\n  }\n\n  return (\n    <div className={cn(\"grid gap-1.5\", className)}>\n      <div className=\"flex items-baseline justify-between gap-3\">\n        <Label\n          htmlFor={fieldId}\n          className={cn(scrub && !disabled && \"cursor-ew-resize\")}\n          onPointerDown={(event) => {\n            if (!scrub || disabled || event.button !== 0) return;\n            scrubOrigin.current = { x: event.clientX, value: current };\n            event.currentTarget.setPointerCapture(event.pointerId);\n          }}\n          onPointerMove={(event) => {\n            const origin = scrubOrigin.current;\n            if (!origin) return;\n            const steps = Math.round((event.clientX - origin.x) / 12);\n            commit(origin.value + steps * step, steps >= 0 ? \"up\" : \"down\");\n          }}\n          onPointerUp={() => {\n            scrubOrigin.current = null;\n          }}\n        >\n          {label}\n        </Label>\n        <p role=\"status\" className=\"text-xs leading-[18px] text-muted-foreground\">\n          {hint}\n        </p>\n      </div>\n      <div\n        data-slot=\"number-field\"\n        data-strain={strain ?? undefined}\n        data-invalid={outside || undefined}\n        className=\"inline-flex h-[var(--control-height)] items-stretch overflow-hidden rounded-control bg-[var(--field)] shadow-[var(--shadow-field)] hover:shadow-[var(--shadow-field-hover)] focus-within:shadow-[var(--shadow-field-focus)] data-[invalid]:shadow-[0_0_0_2px_var(--warning)]\"\n      >\n        <button\n          type=\"button\"\n          aria-label={`Decrease ${label}`}\n          aria-disabled={disabled || current <= lower}\n          disabled={disabled}\n          className=\"grid w-9 place-items-center text-muted-foreground outline-none hover:text-foreground focus-visible:outline-solid focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring aria-disabled:opacity-45\"\n          {...decrease}\n        >\n          <Minus aria-hidden=\"true\" className=\"size-4\" />\n        </button>\n        <div className=\"flex min-w-16 flex-1 items-center justify-center\">\n          {prefix ? <span className=\"pl-1 text-sm text-muted-foreground\">{prefix}</span> : null}\n          <div className=\"relative h-full min-w-8 flex-1\">\n            <input\n              id={fieldId}\n              name={name}\n              role=\"spinbutton\"\n              inputMode={places(step) ? \"decimal\" : \"numeric\"}\n              disabled={disabled}\n              aria-valuenow={current}\n              aria-valuemin={lower}\n              aria-valuemax={upper}\n              aria-valuetext={textOf(current, prefix, suffixText)}\n              value={shown}\n              className={cn(\n                \"h-full w-full min-w-8 bg-transparent text-center text-sm font-medium tabular-nums outline-none\",\n                !focused && \"text-transparent caret-transparent\",\n              )}\n              onFocus={() => setFocused(true)}\n              onBlur={() => {\n                setFocused(false);\n                const next = Number(draft ?? current);\n                if (!Number.isFinite(next)) {\n                  setDraft(null);\n                  return;\n                }\n                commit(next, next > current ? \"up\" : next < current ? \"down\" : \"none\");\n              }}\n              onChange={(event) => setDraft(event.currentTarget.value)}\n              onKeyDown={onKeyDown}\n            />\n            {!focused ? (\n              <span className=\"pointer-events-none absolute inset-0 flex items-center justify-center text-sm\">\n                <RollingValue value={current.toFixed(places(step))} direction={direction} />\n              </span>\n            ) : null}\n          </div>\n          {suffixText ? <span className=\"pr-1 text-sm text-muted-foreground\">{suffixText}</span> : null}\n        </div>\n        <button\n          type=\"button\"\n          aria-label={`Increase ${label}`}\n          aria-disabled={disabled || current >= upper}\n          disabled={disabled}\n          className=\"grid w-9 place-items-center text-muted-foreground outline-none hover:text-foreground focus-visible:outline-solid focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring aria-disabled:opacity-45\"\n          {...increase}\n        >\n          <Plus aria-hidden=\"true\" className=\"size-4\" />\n        </button>\n      </div>\n    </div>\n  );\n}\n"},{"path":"ui/rolling-value.tsx","type":"registry:ui","target":"components/ui/rolling-value.tsx","content":"\"use client\";\n\nimport { useState } from \"react\";\nimport \"./rolling-value.css\";\n\nexport function RollingValue({\n  value,\n  direction,\n}: {\n  value: string;\n  direction: \"up\" | \"down\" | \"none\";\n}) {\n  const [frame, setFrame] = useState({ value, prior: value });\n  if (frame.value !== value) setFrame({ value, prior: frame.value });\n  const prior = frame.prior;\n  const glyphs = [...value];\n\n  return (\n    <span className=\"inline-flex items-center font-medium tabular-nums\" aria-hidden=\"true\">\n      {glyphs.map((glyph, index) => {\n        const changed = prior.length !== glyphs.length || prior[index] !== glyph;\n        return (\n          <span key={`${index}-${glyph}`} className=\"inline-block h-[1.2em] overflow-hidden\">\n            <span\n              data-slot=\"number-digit\"\n              data-direction={changed ? direction : \"none\"}\n              className=\"block\"\n            >\n              {glyph === \" \" ? \"\\u00A0\" : glyph}\n            </span>\n          </span>\n        );\n      })}\n    </span>\n  );\n}\n"},{"path":"ui/shared/chip-option.css","type":"registry:file","target":"components/ui/shared/chip-option.css","content":"/* Shared by calendar.tsx, chart-parts.tsx, chip-group.tsx, data-table.tsx, number-field.tsx, password-field.tsx, stepper.tsx; each imports it. Edit freely. */\n@layer theme, base, components, utilities;\n\n@layer components {\n  /* Depth sets 2 and 3 end */\n\n  /* Press feedback (2026-10-20, motion audit): every pressable control answers a press. Compact controls take the\n     system press scale, as Button does; wide rows deepen their fill instead, since scaling a full-width row reads as a\n     jump. Disabled controls stay still. */\n  :is([data-slot=\"chip-option\"], [data-slot=\"number-field\"] button, [data-slot=\"password-toggle\"], [data-slot=\"calendar\"] button, [data-slot=\"chart-legend-item\"], [data-slot=\"sort-button\"], [data-slot=\"stepper\"] button):active:not(:disabled):not([aria-disabled=\"true\"]) {\n    scale: var(--motion-press-scale);\n  }\n}\n"},{"path":"ui/number-field.css","type":"registry:file","target":"components/ui/number-field.css","content":"/* number-field.tsx's styles; it imports this file. Edit freely. */\n@layer theme, base, components, utilities;\n\n@layer components {\n  [data-slot=\"number-field\"] {\n    transition: translate var(--duration-normal) var(--ease-settle), box-shadow var(--duration-fast) var(--ease-out);\n  }\n  [data-slot=\"number-field\"][data-strain=\"min\"] {\n    translate: -3px 0;\n  }\n  [data-slot=\"number-field\"][data-strain=\"max\"] {\n    translate: 3px 0;\n  }\n}\n"},{"path":"ui/rolling-value.css","type":"registry:file","target":"components/ui/rolling-value.css","content":"/* rolling-value.tsx's styles; it imports this file. Edit freely. */\n@layer theme, base, components, utilities;\n\n@layer components {\n  [data-slot=\"number-digit\"][data-direction=\"up\"] {\n    animation: caliper-digit-in var(--duration-travel) var(--ease-showcase);\n  }\n  [data-slot=\"number-digit\"][data-direction=\"down\"] {\n    animation: caliper-digit-down var(--duration-travel) var(--ease-showcase);\n  }\n\n  @media (prefers-reduced-motion: reduce) {\n    [data-slot=\"number-digit\"][data-direction=\"up\"] {\n      animation: caliper-digit-in 400ms var(--ease-showcase);\n    }\n    [data-slot=\"number-digit\"][data-direction=\"down\"] {\n      animation: caliper-digit-down 400ms var(--ease-showcase);\n    }\n  }\n}\n\n@keyframes caliper-digit-down {\n  from {\n    transform: translateY(70%);\n  }\n  to {\n    transform: translateY(0);\n  }\n}\n"}],"categories":["inputs"],"meta":{"tier":"free","license":"MIT","family":"inputs","docs":"/docs/components/number-field"}}