{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "number-field",
  "type": "registry:ui",
  "title": "Number Field",
  "description": "Steppers you can hold, a label you can drag, digits that roll into place.",
  "categories": [
    "inputs"
  ],
  "dependencies": [
    "@base-ui/react",
    "@number-flow/react"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json"
  ],
  "files": [
    {
      "path": "components/ui/number-field.tsx",
      "content": "\"use client\";\nimport { NumberField as Base } from \"@base-ui/react/number-field\";\nimport NumberFlow, { type Format } from \"@number-flow/react\";\nimport { useId, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { Minus, Plus } from \"@/lib/icons\";\nimport { ease } from \"@/lib/motion\";\n\ntype BaseRootProps = React.ComponentProps<typeof Base.Root>;\n\nexport type NumberFieldProps = Omit<BaseRootProps, \"children\" | \"className\" | \"render\"> & {\n  /** Visible label. Drag it sideways to scrub the value. */\n  label: React.ReactNode;\n  /** A short unit after the number, like \"GB\" or \"seats\". For currency or percent, use `format`. */\n  unit?: string;\n  /** A hint under the field. */\n  description?: React.ReactNode;\n  /** Let the label scrub the value when dragged. */\n  scrub?: boolean;\n  /** Pixels of drag per step while scrubbing. */\n  scrubSensitivity?: number;\n  size?: \"sm\" | \"md\" | \"lg\";\n  placeholder?: string;\n  className?: string;\n};\n\nconst sizes = {\n  sm: { group: \"h-7 rounded-md\", pad: \"pl-2\", btn: \"w-7\", text: \"text-base sm:text-[12.5px]\", icon: 12 },\n  md: { group: \"h-8 rounded-lg\", pad: \"pl-2.5\", btn: \"w-8\", text: \"text-base sm:text-[13px]\", icon: 14 },\n  lg: { group: \"h-9 rounded-lg\", pad: \"pl-3\", btn: \"w-9\", text: \"text-base sm:text-[13px]\", icon: 14 },\n};\n\n// Digits spin on the expo ease-out, quicker than the default so a held stepper or a fast scrub stays legible.\nconst roll = { duration: 420, easing: `cubic-bezier(${ease.out.join(\",\")})` };\n\ntype Reason = Parameters<NonNullable<BaseRootProps[\"onValueChange\"]>>[1][\"reason\"];\nconst typedReasons: Reason[] = [\"input-change\", \"input-clear\", \"input-paste\"];\n\nexport function NumberField({\n  label,\n  unit,\n  description,\n  scrub = true,\n  scrubSensitivity = 4,\n  size = \"md\",\n  placeholder,\n  className,\n  id: idProp,\n  value: valueProp,\n  defaultValue,\n  onValueChange,\n  format,\n  locale,\n  ...rest\n}: NumberFieldProps) {\n  const autoId = useId();\n  const id = idProp ?? autoId;\n  const descId = `${id}-description`;\n  // Mirror the value so the rolling display can follow it whether or not the field is controlled.\n  const [inner, setInner] = useState<number | null>(defaultValue ?? null);\n  const value = valueProp !== undefined ? valueProp : inner;\n  // While someone types, show the real text; any step, scrub or blur hands back to the rolling digits.\n  const [typing, setTyping] = useState(false);\n  const s = sizes[size];\n  const rolling = !typing && value != null;\n\n  return (\n    <Base.Root\n      id={id}\n      value={valueProp}\n      defaultValue={defaultValue}\n      format={format}\n      locale={locale}\n      onValueChange={(next, details) => {\n        setInner(next);\n        setTyping(typedReasons.includes(details.reason));\n        onValueChange?.(next, details);\n      }}\n      className={cn(\"group/number flex min-w-0 flex-col gap-1.5\", className)}\n      {...rest}\n    >\n      {scrub ? (\n        <Base.ScrubArea\n          direction=\"horizontal\"\n          pixelSensitivity={scrubSensitivity}\n          className=\"group/scrub flex w-fit cursor-ew-resize touch-none select-none items-center gap-1 data-disabled:cursor-default data-readonly:cursor-default\"\n        >\n          <Label id={id}>{label}</Label>\n          {/* A quiet ↔ that surfaces on hover, so the scrub is discoverable without a tooltip. */}\n          <svg\n            aria-hidden\n            width=\"12\"\n            height=\"12\"\n            viewBox=\"0 0 16 16\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeWidth=\"1.4\"\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n            className=\"-translate-x-0.5 text-fg-4 opacity-0 transition-[opacity,translate] duration-150 ease-out group-hover/scrub:translate-x-0 group-hover/scrub:opacity-100 group-data-disabled/scrub:hidden group-data-readonly/scrub:hidden group-data-scrubbing/scrub:translate-x-0 group-data-scrubbing/scrub:text-fg-2 group-data-scrubbing/scrub:opacity-100 pointer-coarse:hidden\"\n          >\n            <path d=\"M5.5 5 2.5 8l3 3M10.5 5l3 3-3 3M3 8h10\" />\n          </svg>\n          <Base.ScrubAreaCursor className=\"drop-shadow-[0_1px_1px_var(--overlay)]\">\n            <svg width=\"24\" height=\"14\" viewBox=\"0 0 24 14\" aria-hidden className=\"block\">\n              <path d=\"M18.5 4.8H5.5V1.5L1 7l4.5 5.5V9.2h13v3.3L23 7l-4.5-5.5z\" fill=\"var(--fg)\" stroke=\"var(--frame)\" strokeWidth=\"1\" strokeLinejoin=\"round\" />\n            </svg>\n          </Base.ScrubAreaCursor>\n        </Base.ScrubArea>\n      ) : (\n        <Label id={id}>{label}</Label>\n      )}\n\n      <Base.Group\n        className={cn(\n          \"group/box relative flex w-full items-stretch overflow-hidden border border-line-2 bg-raised text-fg shadow-[var(--shadow)]\",\n          \"transition-[border-color,box-shadow,background-color] duration-150 ease-out\",\n          \"hover:border-fg-4 focus-within:border-fg-3 focus-within:ring-3 focus-within:ring-fg/8 hover:focus-within:border-fg-3\",\n          \"data-scrubbing:border-fg-3 data-scrubbing:ring-3 data-scrubbing:ring-fg/8\",\n          \"data-invalid:border-danger/70 data-invalid:focus-within:border-danger data-invalid:focus-within:ring-danger/15\",\n          \"data-disabled:cursor-not-allowed data-disabled:opacity-50 data-disabled:shadow-none data-disabled:hover:border-line-2\",\n          \"data-readonly:bg-frame data-readonly:shadow-none data-readonly:hover:border-line-2\",\n          s.group,\n        )}\n      >\n        <div className={cn(\"relative flex min-w-0 flex-1 items-center\", s.pad)}>\n          <Base.Input\n            placeholder={placeholder}\n            aria-describedby={description != null ? descId : undefined}\n            onBlur={() => setTyping(false)}\n            className={cn(\n              \"h-full w-full min-w-0 bg-transparent tabular text-fg caret-fg outline-none placeholder:text-fg-4 disabled:cursor-not-allowed\",\n              s.text,\n              // The text stays in place for the caret and selection; the digits drawn over it roll.\n              rolling && \"text-transparent selection:bg-fg/20\",\n            )}\n          />\n          {rolling && (\n            <span aria-hidden className={cn(\"pointer-events-none absolute inset-y-0 left-0 flex items-center whitespace-nowrap tabular text-fg\", s.text, s.pad)}>\n              <NumberFlow\n                value={value}\n                locales={locale}\n                format={format as Format | undefined}\n                spinTiming={roll}\n                transformTiming={roll}\n              />\n            </span>\n          )}\n        </div>\n\n        {unit && (\n          <span aria-hidden className={cn(\"flex shrink-0 select-none items-center pl-1.5 pr-2.5 text-fg-3\", s.text)}>\n            {unit}\n          </span>\n        )}\n\n        <Stepper kind=\"decrement\" size={size} />\n        <Stepper kind=\"increment\" size={size} />\n      </Base.Group>\n\n      {description != null && (\n        <p id={descId} className=\"text-pretty text-[12px] leading-4 text-fg-3\">\n          {description}\n        </p>\n      )}\n    </Base.Root>\n  );\n}\n\nfunction Label({ id, children }: { id: string; children: React.ReactNode }) {\n  return (\n    <label\n      htmlFor={id}\n      className=\"cursor-[inherit] text-[12.5px] font-medium text-fg-2 transition-colors duration-150 group-hover/scrub:text-fg group-data-scrubbing/scrub:text-fg group-data-disabled/number:text-fg-3\"\n    >\n      {children}\n    </label>\n  );\n}\n\nfunction Stepper({ kind, size }: { kind: \"increment\" | \"decrement\"; size: NonNullable<NumberFieldProps[\"size\"]> }) {\n  const Part = kind === \"increment\" ? Base.Increment : Base.Decrement;\n  const s = sizes[size];\n  const Icon = kind === \"increment\" ? Plus : Minus;\n  return (\n    <Part\n      aria-label={kind === \"increment\" ? \"Increase\" : \"Decrease\"}\n      className={cn(\n        \"group/step relative grid shrink-0 place-items-center border-l border-line text-fg-3 outline-none\",\n        \"transition-[background-color,color] duration-150 ease-out hover:bg-hover hover:text-fg active:bg-hover\",\n        \"focus-visible:bg-hover focus-visible:text-fg\",\n        \"data-disabled:pointer-events-none data-disabled:text-fg-4 data-disabled:opacity-60\",\n        // Touch gets a 44px target without the button drawing any bigger.\n        \"before:absolute before:inset-x-0 before:-inset-y-1.5 before:content-[''] pointer-coarse:before:-inset-y-2\",\n        s.btn,\n      )}\n    >\n      <Icon\n        size={s.icon}\n        className=\"transition-transform duration-150 ease-out group-active/step:scale-[0.8] group-active/step:duration-75 motion-reduce:transition-none\"\n      />\n    </Part>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/number-field"
}
