组件
验证码输入框 InputOTP
专为一次性验证码(OTP)、PIN 密码与安全兑换码设计的分格输入组件,内置剪贴板智能分发、方向键跳格与完成回调。
基础用法
标准 6 位数字验证码输入。在任意格输入数字自动跳转下一格,支持直接复制粘贴完整验证码:
Loading…
安装与引入
通过 CLI 自动添加组件,或手动复制源码至项目中:
pnpm dlx @wui-design/cli@latest add @wui/input-otp安装基础依赖
pnpm add clsx tailwind-merge复制组件源码到
components/ui/input-otp.tsx"use client"
import * as React from "react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { cn } from "@/lib/utils"
export interface InputOTPProps extends Omit<
React.ComponentProps<"div">,
"defaultValue" | "onChange"
> {
/** 验证码内容;传入后组件进入受控模式。 */
value?: string
/** 非受控模式下的初始内容。 */
defaultValue?: string
/** 输入位数。 @default 6 */
length?: number
/** 内容变化时触发。 */
onValueChange?: (value: string) => void
/** 所有输入位填满时触发。 */
onComplete?: (value: string) => void
/** 是否只接受数字。 @default true */
numeric?: boolean
/** 是否以圆点遮挡已输入内容,适用于支付密码等敏感场景。 @default false */
mask?: boolean
/** 是否禁用全部输入位。 */
disabled?: boolean
/** 标记为校验失败,输入位边框切换为危险色。 */
invalid?: boolean
/** 单个输入框的样式。 */
inputClassName?: string
/** 验证码输入组的无障碍名称。 @default "验证码" */
"aria-label"?: string
}
/** 支持粘贴分发、短信自动填充、键盘移动和完成回调的分格验证码输入框。 */
function InputOTP({
value,
defaultValue = "",
length = 6,
onValueChange,
onComplete,
numeric = true,
mask = false,
disabled,
invalid = false,
inputClassName,
className,
"aria-label": ariaLabel = "验证码",
...props
}: InputOTPProps) {
const reduceMotion = useReducedMotion()
const layoutId = React.useId()
const controlled = value !== undefined
const [internalValue, setInternalValue] = React.useState(defaultValue)
const [focusedIndex, setFocusedIndex] = React.useState<number | null>(null)
const currentValue = (value ?? internalValue).slice(0, length)
const refs = React.useRef<Array<HTMLInputElement | null>>([])
// Focus moves synchronously inside change handlers, before React re-renders,
// so the latest length is tracked in a ref rather than read from render scope.
const lengthRef = React.useRef(currentValue.length)
lengthRef.current = currentValue.length
const normalize = React.useCallback(
(text: string) =>
(numeric ? text.replace(/\D/g, "") : text.replace(/\s/g, "")).slice(0, length),
[length, numeric]
)
const update = (next: string, focusIndex?: number) => {
const normalized = normalize(next)
lengthRef.current = normalized.length
if (!controlled) setInternalValue(normalized)
onValueChange?.(normalized)
if (normalized.length === length && normalized !== currentValue) onComplete?.(normalized)
if (focusIndex !== undefined) refs.current[focusIndex]?.focus()
}
const fillFrom = (index: number, text: string) => {
const next = currentValue.slice(0, index) + text
update(next, Math.min(next.length, length - 1))
}
const characters = Array.from(
{ length },
(_, index) => currentValue[index] ?? ""
)
// Slots after the first empty one cannot hold a character yet, so focus is redirected.
const firstEmpty = Math.min(currentValue.length, length - 1)
const springTransition = reduceMotion
? { duration: 0 }
: { type: "spring" as const, stiffness: 520, damping: 38, mass: 0.7 }
return (
<div
data-slot="input-otp"
role="group"
aria-label={ariaLabel}
data-invalid={invalid || undefined}
className={cn("flex items-center gap-2", className)}
{...props}
>
{characters.map((character, index) => {
const focused = focusedIndex === index
return (
<div
key={index}
data-slot="input-otp-slot"
data-active={focused || undefined}
data-filled={character ? true : undefined}
className={cn(
"border-input bg-background shadow-xs relative flex size-10 shrink-0 items-center justify-center rounded-md border text-base font-medium tabular-nums transition-[border-color,background-color] duration-200 has-[input:disabled]:cursor-not-allowed has-[input:disabled]:opacity-50",
character && "border-ring/60",
invalid && "border-destructive",
inputClassName
)}
>
<input
ref={(node) => {
refs.current[index] = node
}}
aria-label={`第 ${index + 1} 位,共 ${length} 位`}
aria-invalid={invalid || undefined}
autoComplete={index === 0 ? "one-time-code" : "off"}
inputMode={numeric ? "numeric" : "text"}
pattern={numeric ? "[0-9]*" : undefined}
type={mask ? "password" : "text"}
// Roving tab stop: Tab enters at the next slot to fill and then leaves the group.
tabIndex={index === firstEmpty ? 0 : -1}
value={character}
disabled={disabled}
className="absolute inset-0 size-full cursor-text rounded-[inherit] bg-transparent text-center text-transparent caret-transparent outline-none selection:bg-transparent disabled:cursor-not-allowed"
onFocus={(event) => {
const nextEmpty = Math.min(lengthRef.current, length - 1)
if (index > nextEmpty) {
refs.current[nextEmpty]?.focus()
return
}
setFocusedIndex(index)
event.currentTarget.select()
}}
onBlur={() => setFocusedIndex((current) => (current === index ? null : current))}
onChange={(event) => {
const incoming = normalize(event.target.value)
if (!incoming) return
// One-time-code autofill and IME commits can deliver several characters at once.
if (incoming.length > 2) {
fillFrom(index, incoming)
return
}
// Typing into a filled slot without a selection yields the old and new character.
const typed =
incoming.length === 2 && character
? incoming.replace(character, "")
: incoming.at(-1)
const next = characters.slice()
next[index] = typed ?? ""
update(next.join(""), Math.min(index + 1, length - 1))
}}
onPaste={(event) => {
event.preventDefault()
const pasted = normalize(event.clipboardData.getData("text"))
if (pasted) fillFrom(index, pasted)
}}
onKeyDown={(event) => {
if (event.key === "Backspace") {
event.preventDefault()
const target = character ? index : Math.max(index - 1, 0)
update(characters.slice(0, target).join(""), target)
} else if (event.key === "ArrowLeft") {
event.preventDefault()
refs.current[Math.max(index - 1, 0)]?.focus()
} else if (event.key === "ArrowRight") {
event.preventDefault()
refs.current[Math.min(index + 1, length - 1)]?.focus()
} else if (event.key === "Delete") {
event.preventDefault()
update(characters.slice(0, index).join(""))
}
}}
/>
{focused ? (
<motion.span
aria-hidden="true"
data-slot="input-otp-active"
layoutId={layoutId}
className={cn(
"pointer-events-none absolute -inset-px rounded-[inherit] border ring-[3px]",
invalid ? "border-destructive ring-destructive/20" : "border-ring ring-ring/30"
)}
transition={springTransition}
/>
) : null}
<AnimatePresence initial={false} mode="popLayout">
{character ? (
<motion.span
key={`${index}-${character}`}
aria-hidden="true"
className="pointer-events-none"
initial={reduceMotion ? false : { opacity: 0, y: 6, scale: 0.6 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.6, transition: { duration: 0.12 } }}
transition={springTransition}
>
{mask ? (
<span className="block size-2.5 rounded-full bg-foreground" />
) : (
character
)}
</motion.span>
) : focused ? (
<motion.span
key="caret"
aria-hidden="true"
data-slot="input-otp-caret"
className="pointer-events-none h-[1.1em] w-px bg-foreground"
initial={{ opacity: 1 }}
animate={reduceMotion ? { opacity: 1 } : { opacity: [1, 1, 0, 0] }}
exit={{ opacity: 0, transition: { duration: 0 } }}
transition={
reduceMotion
? { duration: 0 }
: { duration: 1.1, times: [0, 0.45, 0.55, 1], repeat: Infinity, ease: "easeInOut" }
}
/>
) : null}
</AnimatePresence>
</div>
)
})}
</div>
)
}
export { InputOTP }
属性 Props
InputOTP 支持以下配置属性,并继承外层 <div role="group"> 的 HTML 属性:
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| value | string | — | 受控模式下的完整验证码字符串内容。 |
| defaultValue | string | "" | 非受控模式下的初始验证码内容。 |
| length | number | 6 | 验证码的总分格位数(如 4 位 PIN 码或 6 位短信验证码)。 |
| numeric | boolean | true | 是否仅允许输入纯数字(自动过滤非数字字符并在移动端调起数字键盘)。 |
| onValueChange | (value: string) => void | — | 验证码任一分格内容发生变动时触发的回调函数,参数为当前已输入的完整字符串。 |
| onComplete | (value: string) => void | — | 当所有分格全部输入填满(达到指定 `length`)时触发的回调,便于自动触发提交验证。 |
| mask | boolean | false | 是否以圆点遮挡已输入的字符,适用于支付密码、二级解锁等敏感场景。 |
| invalid | boolean | false | 标记校验失败,所有分格与激活高亮切换为危险色,并为每个输入位设置 `aria-invalid`。 |
| disabled | boolean | false | 是否禁用全部验证码输入格。 |
| inputClassName | string | — | 应用于每个分格容器的额外 CSS 类名,可用于自定义格尺寸与字体样式(如 `size-12 text-lg`)。 |
| aria-label | string | "验证码" | 整组验证码输入框对屏幕阅读器公开的无障碍描述名称。 |
| className | string | — | 应用于外层容器的额外 CSS 类名。 |
事件 Events
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| onValueChange | (value: string) => void | — | 用户输入、退格删除或粘贴内容使整体值变化时触发。 |
| onComplete | (value: string) => void | — | 当所有格子全部填满时触发,返回完整的验证码字符串,常用于无感自动请求后台接口。 |
使用场景与设计规范
InputOTP 适用于短小固定长度、即时验证的安全校验场景(如手机短信验证码、邮箱二次认证、支付密码、安全令牌)。
- InputOTP vs 普通 Input:
- 固定位数的短信/邮箱验证码使用
InputOTP,分格视觉能明确告知用户所需长度并支持自动跳格。 - 变长密码、身份证号或常规文本请使用普通
Input,避免给用户造成打断感。
- 固定位数的短信/邮箱验证码使用
- 智能剪贴板粘贴(Clipboard Paste):无论焦点处于第 1 格还是中间某格,直接 Ctrl + V 粘贴完整 6 位码,组件会自动截取对应长度并顺序填入所有格子,并立即触发
onComplete。 - 移动端适配:在
numeric={true}模式下,组件为首个格子配置了autoComplete="one-time-code"与inputMode="numeric",iOS / Android 系统键盘可一键识别短信验证码并自动填充。
场景示例
短信认证流程与倒计时
真实业务中的 6 位手机短信验证流程,集成倒计时重发、invalid 错误反馈与填满后自动提交。输入时字符会轻微弹入,激活高亮在分格之间滑动,空白的激活格显示闪烁光标:
Loading…
支付密码 (Mask)
开启 mask 后已输入的字符以圆点显示,并通过 inputClassName 放大输入格,适用于支付确认与二级解锁:
Loading…
字母与数字混合的兑换码
设置 numeric={false} 支持字母、数字混合的 8 位企业兑换码或序列号:
Loading…
无障碍与交互 Accessibility
- ARIA 角色与分组:外层容器具有
role="group"和aria-label="验证码",每个单格具有细化描述aria-label="第 X 位,共 Y 位"。 - 键盘导航交互:
- Backspace:清除当前格子;若当前格已为空,光标自动回退至前一个格子并清除内容。
- ← / →:在不同分格之间平滑穿梭移动焦点。
- Delete:清除当前格子内容而不移动光标。
- 高对比度焦点环:激活格子提供清晰的环绕焦点高亮,并在分格之间平滑移动,确保弱视与键盘操作用户随时辨识当前光标位置。
- 顺序输入:点击尚不能输入的靠后分格时,焦点会自动回到第一个空格,避免出现“跳格”输入。
- 短信自动填充:首格设置
autoComplete="one-time-code",系统一次性填入的完整验证码会被自动分发到各个分格。 - 动效降级:字符弹入、光标闪烁与高亮滑动在
prefers-reduced-motion下全部关闭。