wui
组件

验证码输入框 InputOTP

专为一次性验证码(OTP)、PIN 密码与安全兑换码设计的分格输入组件,内置剪贴板智能分发、方向键跳格与完成回调。

第三方依赖 · motion

基础用法

标准 6 位数字验证码输入。在任意格输入数字自动跳转下一格,支持直接复制粘贴完整验证码:

Loading…

安装与引入

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

pnpm dlx @wui-design/cli@latest add @wui/input-otp
安装基础依赖
pnpm add clsx tailwind-merge
复制组件源码到 components/ui/input-otp.tsx
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 属性:

属性类型默认值说明
valuestring—受控模式下的完整验证码字符串内容。
defaultValuestring""非受控模式下的初始验证码内容。
lengthnumber6验证码的总分格位数(如 4 位 PIN 码或 6 位短信验证码)。
numericbooleantrue是否仅允许输入纯数字(自动过滤非数字字符并在移动端调起数字键盘)。
onValueChange(value: string) => void—验证码任一分格内容发生变动时触发的回调函数,参数为当前已输入的完整字符串。
onComplete(value: string) => void—当所有分格全部输入填满(达到指定 `length`)时触发的回调,便于自动触发提交验证。
maskbooleanfalse是否以圆点遮挡已输入的字符,适用于支付密码、二级解锁等敏感场景。
invalidbooleanfalse标记校验失败,所有分格与激活高亮切换为危险色,并为每个输入位设置 `aria-invalid`。
disabledbooleanfalse是否禁用全部验证码输入格。
inputClassNamestring—应用于每个分格容器的额外 CSS 类名,可用于自定义格尺寸与字体样式(如 `size-12 text-lg`)。
aria-labelstring"验证码"整组验证码输入框对屏幕阅读器公开的无障碍描述名称。
classNamestring—应用于外层容器的额外 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 下全部关闭。