组件
数字输入框 InputNumber
专为数值录入设计的精度步进输入组件,支持上下范围约束、浮点数精度保持、键盘加速微调与表单校验。
基础用法
最简单的数字输入框用法。直接在输入框中键入数字,或点击右侧的加减按钮进行步进调整。步进时数字会按增减方向上下滚动;按住按钮可连续步进,到达边界时自动停止:
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"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):
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| value | number | null | — | 受控模式下的当前数值,清空输入框时为 null。 |
| defaultValue | number | — | 非受控模式下的初始数值。 |
| onValueChange | (value: number | null) => void | — | 数值发生改变时触发的回调函数,返回最新解析的数字或 null。 |
| min | number | — | 允许输入的数值下限(最小值),低于该值时在失焦或步进时自动收敛。 |
| max | number | — | 允许输入的数值上限(最大值),高于该值时在失焦或步进时自动收敛。 |
| step | number | 1 | 每次点击步进按钮或按键盘方向键时增减的步长数值,支持小数(如 0.1);按住 Shift 使用方向键时为 10 倍步长。 |
| prefix | React.ReactNode | — | 位于输入框前置区域的自定义内容(如货币符号 ¥、$)。 |
| suffix | React.ReactNode | — | 位于输入框后置区域的自定义内容(如单位 kg、%、GiB)。 |
| size | "sm" | "default" | "lg" | "default" | 输入框与步进按钮的物理高度与尺寸密度。 |
| disabled | boolean | false | 是否禁用输入框与全部步进按钮。 |
| readOnly | boolean | false | 是否设为只读模式(允许聚焦与复制,但禁止修改数值与点击步进)。 |
| wrapperClassName | string | — | 应用于外层边框与按钮容器的额外 CSS 类名。 |
| className | string | — | 应用于内部原生 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),防止无效越界操作。