wui
组件

数字输入框 InputNumber

专为数值录入设计的精度步进输入组件,支持上下范围约束、浮点数精度保持、键盘加速微调与表单校验。

第三方依赖 · lucide-react第三方依赖 · motion第三方依赖 · class-variance-authority

基础用法

最简单的数字输入框用法。直接在输入框中键入数字,或点击右侧的加减按钮进行步进调整。步进时数字会按增减方向上下滚动;按住按钮可连续步进,到达边界时自动停止:

Loading…

安装与引入

通过 CLI 自动添加组件,或手动复制源码至项目中:

pnpm dlx @wui-design/cli@latest add @wui/input-number
安装依赖与工具库
pnpm add lucide-react class-variance-authority clsx tailwind-merge
复制组件源码到 components/ui/input-number.tsx
components/ui/input-number.tsx
"use client"

import * as React from "react"
import { Minus, Plus } from "lucide-react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { cva } from "class-variance-authority"

import { cn } from "@/lib/utils"

const inputNumberVariants = cva(
  "border-input bg-background shadow-xs focus-within:border-ring focus-within:ring-ring/30 has-[input[aria-invalid=true]]:border-destructive has-[input[aria-invalid=true]]:ring-destructive/20 has-[input[aria-invalid=true]]:ring-[3px] flex w-full items-center overflow-hidden rounded-md border transition-[border-color,box-shadow] duration-200 ease-out focus-within:ring-[3px] has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-50 motion-reduce:transition-none",
  {
    variants: {
      size: {
        sm: "h-8 text-xs",
        default: "h-10 text-sm",
        lg: "h-12 text-base",
      },
    },
    defaultVariants: {
      size: "default",
    },
  }
)

const controlBtnVariants = cva(
  "group/step text-muted-foreground hover:bg-accent hover:text-foreground active:bg-accent/70 inline-flex touch-none select-none items-center justify-center outline-none transition-colors disabled:pointer-events-none disabled:opacity-40",
  {
    variants: {
      size: {
        sm: "w-7",
        default: "w-9",
        lg: "w-11",
      },
    },
    defaultVariants: {
      size: "default",
    },
  }
)

const HOLD_DELAY = 380
const HOLD_INTERVAL = 70

export interface InputNumberProps
  extends Omit<
    React.ComponentProps<"input">,
    "defaultValue" | "onChange" | "type" | "value" | "prefix" | "size"
  > {
  /** 当前数值;传入后组件进入受控模式。 */
  value?: number | null
  /** 非受控模式下的初始数值。 */
  defaultValue?: number
  /** 数值变化时触发,清空输入时返回 `null`。 */
  onValueChange?: (value: number | null) => void
  /** 允许输入的最小值。 */
  min?: number
  /** 允许输入的最大值。 */
  max?: number
  /** 点击按钮或按方向键时的步进值;按住 Shift 时为 10 倍步进。 @default 1 */
  step?: number
  /** 前缀插槽(如货币符号 ¥、$)。 */
  prefix?: React.ReactNode
  /** 后缀插槽(如单位 kg、%、GiB)。 */
  suffix?: React.ReactNode
  /** 尺寸密度。@default "default" */
  size?: "sm" | "default" | "lg"
  /** 输入框外层容器样式。 */
  wrapperClassName?: string
}

/** 支持范围约束、长按连续步进、方向感知滚动动效和键盘操作的数值输入框。 */
function InputNumber({
  value,
  defaultValue,
  onValueChange,
  min,
  max,
  step = 1,
  prefix,
  suffix,
  size = "default",
  disabled,
  readOnly,
  className,
  wrapperClassName,
  onBlur,
  onKeyDown,
  ...props
}: InputNumberProps) {
  const reduceMotion = useReducedMotion()
  const controlled = value !== undefined
  const [internalValue, setInternalValue] = React.useState<number | null>(
    defaultValue ?? null
  )
  const currentValue = controlled ? value : internalValue
  const [draft, setDraft] = React.useState(
    currentValue === null || currentValue === undefined
      ? ""
      : String(currentValue)
  )
  // A stepped value slides in from the direction it came from; `rolling` hides
  // the native text while the overlay animates, then hands display back to it.
  const [roll, setRoll] = React.useState({ id: 0, direction: 1 as 1 | -1 })
  const [rolling, setRolling] = React.useState(false)
  const valueRef = React.useRef(currentValue)
  const holdRef = React.useRef<{ timeout?: number; interval?: number }>({})
  valueRef.current = currentValue

  React.useEffect(() => {
    if (!controlled) return
    const parsedDraft = draft.trim() === "" ? null : Number(draft)
    if (value !== parsedDraft) setDraft(value === null ? "" : String(value))
    // `draft` intentionally stays out of this dependency list so intermediate
    // input such as `-` and `1.` is not replaced while the user is typing.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [controlled, value])

  React.useEffect(() => stopHold, [])

  const precision = Math.max(
    String(step).split(".")[1]?.length ?? 0,
    String(min ?? "").split(".")[1]?.length ?? 0,
    String(max ?? "").split(".")[1]?.length ?? 0
  )

  const clamp = (next: number) => {
    const bounded = Math.min(max ?? Infinity, Math.max(min ?? -Infinity, next))
    return Number(bounded.toFixed(precision))
  }

  const commit = (next: number | null) => {
    valueRef.current = next
    if (!controlled) setInternalValue(next)
    setDraft(next === null ? "" : String(next))
    onValueChange?.(next)
  }

  const stepBy = (direction: 1 | -1, multiplier = 1) => {
    if (disabled || readOnly) return false
    const latest = valueRef.current
    const base = latest ?? (direction > 0 ? (min ?? 0) : (max ?? 0))
    const next = clamp(base + direction * step * multiplier)
    if (next === latest) return false
    if (!reduceMotion) {
      setRoll((previous) => ({ id: previous.id + 1, direction }))
      setRolling(true)
    }
    commit(next)
    return true
  }

  function stopHold() {
    window.clearTimeout(holdRef.current.timeout)
    window.clearInterval(holdRef.current.interval)
    holdRef.current = {}
  }

  function startHold(direction: 1 | -1) {
    stopHold()
    stepBy(direction)
    holdRef.current.timeout = window.setTimeout(() => {
      holdRef.current.interval = window.setInterval(() => {
        if (!stepBy(direction)) stopHold()
      }, HOLD_INTERVAL)
    }, HOLD_DELAY)
  }

  const atMin =
    currentValue !== null &&
    currentValue !== undefined &&
    min !== undefined &&
    currentValue <= min
  const atMax =
    currentValue !== null &&
    currentValue !== undefined &&
    max !== undefined &&
    currentValue >= max

  function stepButtonProps(direction: 1 | -1) {
    return {
      type: "button" as const,
      tabIndex: -1,
      className: cn(controlBtnVariants({ size }), direction > 0 && "border-l"),
      disabled: disabled || readOnly || (direction > 0 ? atMax : atMin),
      onMouseDown: (event: React.MouseEvent) => event.preventDefault(),
      onPointerDown: (event: React.PointerEvent) => {
        if (event.button !== 0) return
        event.currentTarget.setPointerCapture(event.pointerId)
        startHold(direction)
      },
      onPointerUp: stopHold,
      onPointerCancel: stopHold,
      onLostPointerCapture: stopHold,
      // Keyboard or assistive-technology activation arrives as a click without a pointer.
      onClick: (event: React.MouseEvent) => {
        if (event.detail === 0) stepBy(direction)
      },
    }
  }

  const iconClassName =
    "size-3.5 transition-transform duration-150 ease-out group-active/step:scale-75 motion-reduce:transition-none"

  return (
    <div
      data-slot="input-number"
      className={cn(inputNumberVariants({ size }), wrapperClassName)}
    >
      {prefix ? (
        <span className="text-muted-foreground pl-3 pr-1 select-none shrink-0 font-medium">
          {prefix}
        </span>
      ) : null}

      <div className="relative h-full min-w-0 flex-1">
        <input
          data-slot="input-number-input"
          type="text"
          inputMode="decimal"
          role="spinbutton"
          aria-valuemin={min}
          aria-valuemax={max}
          aria-valuenow={currentValue ?? undefined}
          value={draft}
          disabled={disabled}
          readOnly={readOnly}
          className={cn(
            "placeholder:text-muted-foreground size-full bg-transparent px-3 tabular-nums outline-none disabled:cursor-not-allowed",
            prefix && "pl-1",
            suffix && "pr-1",
            rolling && "text-transparent",
            className
          )}
          onChange={(event) => {
            const nextDraft = event.target.value
            setRolling(false)
            setDraft(nextDraft)
            if (nextDraft.trim() === "") {
              valueRef.current = null
              if (!controlled) setInternalValue(null)
              onValueChange?.(null)
              return
            }
            const parsed = Number(nextDraft)
            if (Number.isFinite(parsed)) {
              valueRef.current = parsed
              if (!controlled) setInternalValue(parsed)
              onValueChange?.(parsed)
            }
          }}
          onBlur={(event) => {
            const parsed = Number(draft)
            if (draft.trim() === "" || !Number.isFinite(parsed)) commit(null)
            else commit(clamp(parsed))
            onBlur?.(event)
          }}
          onKeyDown={(event) => {
            if (event.key === "ArrowUp" || event.key === "ArrowDown") {
              event.preventDefault()
              const multiplier = event.shiftKey ? 10 : 1
              stepBy(event.key === "ArrowUp" ? 1 : -1, multiplier)
            }
            onKeyDown?.(event)
          }}
          {...props}
        />

        {!reduceMotion ? (
          <span
            aria-hidden="true"
            data-slot="input-number-roll"
            className={cn(
              "pointer-events-none absolute inset-0 flex items-center overflow-hidden px-3 tabular-nums",
              prefix && "pl-1",
              suffix && "pr-1",
              !rolling && "invisible",
              className
            )}
          >
            <AnimatePresence initial={false} custom={roll.direction}>
              <motion.span
                key={roll.id}
                custom={roll.direction}
                className="absolute whitespace-pre"
                variants={{
                  enter: (direction: number) => ({ y: `${direction * 70}%`, opacity: 0 }),
                  center: { y: "0%", opacity: 1 },
                  exit: (direction: number) => ({ y: `${direction * -70}%`, opacity: 0 }),
                }}
                initial="enter"
                animate="center"
                exit="exit"
                transition={{ type: "spring", stiffness: 520, damping: 38, mass: 0.7 }}
                onAnimationComplete={(definition) => {
                  if (definition === "center") setRolling(false)
                }}
              >
                {draft}
              </motion.span>
            </AnimatePresence>
          </span>
        ) : null}
      </div>

      {suffix ? (
        <span className="text-muted-foreground pl-1 pr-2.5 select-none shrink-0 font-normal">
          {suffix}
        </span>
      ) : null}

      <div
        data-slot="input-number-controls"
        className="flex h-full shrink-0 border-l"
      >
        <button
          data-slot="input-number-decrement"
          aria-label="减小数值"
          {...stepButtonProps(-1)}
        >
          <Minus className={iconClassName} />
        </button>
        <button
          data-slot="input-number-increment"
          aria-label="增大数值"
          {...stepButtonProps(1)}
        >
          <Plus className={iconClassName} />
        </button>
      </div>
    </div>
  )
}

export { InputNumber, inputNumberVariants }

属性 Props

InputNumber 支持以下核心属性,并继承原生 <input> 的 HTML 属性(已排除原生 value、defaultValue、onChange 与 type):

属性类型默认值说明
valuenumber | null—受控模式下的当前数值,清空输入框时为 null。
defaultValuenumber—非受控模式下的初始数值。
onValueChange(value: number | null) => void—数值发生改变时触发的回调函数,返回最新解析的数字或 null。
minnumber—允许输入的数值下限(最小值),低于该值时在失焦或步进时自动收敛。
maxnumber—允许输入的数值上限(最大值),高于该值时在失焦或步进时自动收敛。
stepnumber1每次点击步进按钮或按键盘方向键时增减的步长数值,支持小数(如 0.1);按住 Shift 使用方向键时为 10 倍步长。
prefixReact.ReactNode—位于输入框前置区域的自定义内容(如货币符号 ¥、$)。
suffixReact.ReactNode—位于输入框后置区域的自定义内容(如单位 kg、%、GiB)。
size"sm" | "default" | "lg""default"输入框与步进按钮的物理高度与尺寸密度。
disabledbooleanfalse是否禁用输入框与全部步进按钮。
readOnlybooleanfalse是否设为只读模式(允许聚焦与复制,但禁止修改数值与点击步进)。
wrapperClassNamestring—应用于外层边框与按钮容器的额外 CSS 类名。
classNamestring—应用于内部原生 input 文本输入元素的额外 CSS 类名。

事件 Events

属性类型默认值说明
onValueChange(value: number | null) => void—用户输入有效数值、清空输入框或通过步进操作变更数值时触发。
onBlur(event: React.FocusEvent<HTMLInputElement>) => void—输入框失去焦点时触发,此时组件会自动执行范围收敛(Clamp)与精度格式化。
onFocus(event: React.FocusEvent<HTMLInputElement>) => void—输入框获得焦点时触发。
onKeyDown(event: React.KeyboardEvent<HTMLInputElement>) => void—按下键盘按键时触发;按方向键进行增减时会先触发该回调后执行步进。

使用场景与设计规范

InputNumber 适用于需要明确数值计算、范围约束与微调步进的输入项:

  • InputNumber vs 普通 Input:对于电话号码、邮政编码、身份证号等由数字构成的纯文本标识,必须使用普通 Input(或添加输入掩码),因为它们不参与数学运算且前导零(如 010)具有业务含义;只有金额、数量、库存、百分比等需要算术计算的字段才使用 InputNumber。
  • InputNumber vs Slider:当用户需要在大范围中进行直观概览或模糊拖拽调节(如音量、亮度)时,使用 Slider;当用户需要输入精准数字或同时支持键盘微调时,使用 InputNumber(两者也可在复杂表单中组合联动)。
  • 浮点精度保持机制:组件会自动根据 step、min、max 的小数位数动态推导计算精度(如 step={0.01} 会自动保留 2 位小数),有效规避 JavaScript 中经典的 0.1 + 0.2 = 0.30000000000000004 浮点误差。
  • 输入平滑性:在用户打字输入过程中(例如输入负号 - 或末尾小数点 1. 时),组件不会粗暴打断用户,而是在失焦(Blur)或步进操作时完成最终数值收敛。

场景示例

受控模式与计算联动

结合 value 与 onValueChange 实现外部总价动态结算与快速预设赋值:

Loading…
const [count, setCount] = React.useState<number | null>(3)

return (
  <InputNumber
    value={count}
    onValueChange={setCount}
    min={1}
    max={50}
    suffix="台"
  />
)

尺寸规格

提供 sm(32px)、default(40px)和 lg(48px)三种尺寸规格,配合前缀与后缀使用:

Loading…
  • sm:紧凑尺寸,适合表格行内编辑单元格或高密度配置面板。
  • default:标准尺寸,通用表单与设置页面。
  • lg:大尺寸,适用于订单确认页或移动端触控场景。

浮点精度与自定义步长

支持指定任意小数步长与范围边界,键盘配合 Shift 键可触发 10 倍快速步进:

Loading…

禁用与只读状态

支持通过 disabled 禁用交互,或通过 readOnly 保留可选中复制能力同时禁止编辑:

Loading…

资源配额与表单应用

在服务器计算资源分配等真实业务表单中,将多个数字输入框与单位后缀组合:

Loading…

无障碍与交互 Accessibility

  • ARIA 语义与读屏支持:
    • 原生输入元素被赋予 role="spinbutton" 语义。
    • 同步挂载 aria-valuemin、aria-valuemax 与 aria-valuenow,屏幕阅读器能准确获知当前数值及其合法上下限。
  • 移动端键盘适配:
    • 设置 inputMode="decimal",移动端设备(iOS/Android)在聚焦时会自动呼出带小数点的数字专用虚拟键盘,提升录入体验。
  • 键盘快捷键:
    • ↑:数值增加一个 step。
    • ↓:数值减少一个 step。
    • Shift + ↑:数值增加 10 倍 step(加速步进)。
    • Shift + ↓:数值减少 10 倍 step(加速步减)。
  • 边界保护:当数值达到 min 或 max 极限时,对应的加减按钮会自动进入禁用状态(disabled),防止无效越界操作。