拟态验证码输入

otp · neumorphic · 灵感来源:Apple 短信验证码自动填充实践 / GOV.UK passwords

每格一个槽,当前格再压一圈 accent

6 位数字,5 分钟内有效

错误与禁用

验证码不正确或已过期
失败 5 次,10 分钟后可再试

复用卡

One-Time Code Input · 拟态验证码输入 · 验证码 / Neumorphic

规格 · 术语提示词(给 AI 用)

做一个 One-Time Code Input(拟态验证码输入):
- 同一份行为与语义(单输入框 + 画的格子)
- 每格是挤进去的槽(inset 双阴影无边框),聚焦时当前格再加一圈 accent 内阴影
- 没有边框可用,错误态只能把已填数字换语义色 + 靠 errorMessage 播报
- 键盘与自动填充口径同玻璃验证码输入
用这些 CSS 变量:--color-accent --color-text --color-text-muted --color-text-subtle --color-warning-fg --glass-border --glass-recess --neuro-bg --neuro-dark --neuro-light --text-2xs --text-base --text-xs
灵感参考:Apple 短信验证码自动填充实践 / GOV.UK passwords

参数(CSS 变量)

--color-accent --color-text --color-text-muted --color-text-subtle --color-warning-fg --glass-border --glass-recess --neuro-bg --neuro-dark --neuro-light --text-2xs --text-base --text-xs

必缺状态

焦点 / 禁用 / 错误

整页场景

(尚未接进整页场景)

安装(一条链接)

调研笔记

拟态验证码输入

  • 同一份行为与语义(单输入框 + 画的格子、autocomplete="one-time-code"、焦点环在格子组上),见玻璃那支的 notes
  • 材质签名:每格都是挤进去的槽(inset 双阴影、无边框),聚焦时当前格在槽内再加一圈 accent 内阴影表示"填这里"
  • 代价:拟态没有边框,错误态不能靠描边表达,只能把已填数字换成语义色 + 靠 errorMessage 播报

实现

otp/neumorphic
import { type ChangeEvent } from 'react';
import { Field } from '~/components/Field';

interface Props {
  label?: string;
  hint?: string;
  /** 错误态:语义色描边 + aria-invalid */
  error?: boolean;
  /** 错误文案:盖过 hint 显示,并挂 role="alert" */
  errorMessage?: string;
  /** 格数,同时是最大长度 */
  length?: number;
  value: string;
  onChange?: (value: string) => void;
  disabled?: boolean;
  /** 系统自动填充:iOS / Android 会把短信验证码直接填进这一格 */
  autocomplete?: 'one-time-code' | 'off';
  className?: string;
}

const onlyDigits = (raw: string, length: number) => raw.replace(/\D/g, '').slice(0, length);

/** 拟态一次性验证码:每一格都是挤进去的槽,当前格再加一圈 accent 内阴影表示"填这里" */
export function NeumorphicOtp({
  label,
  hint,
  error = false,
  errorMessage,
  length = 6,
  value,
  onChange,
  disabled = false,
  autocomplete = 'one-time-code',
  className = '',
}: Props) {
  const cells = Array.from({ length }, (_, i) => i);

  return (
    <Field label={label} hint={hint} error={error} errorMessage={errorMessage} className={className}>
      {(field) => (
        <div className="relative inline-block">
          <input
            id={field.id}
            type="text"
            inputMode="numeric"
            autoComplete={autocomplete}
            pattern="[0-9]*"
            maxLength={length}
            value={value}
            disabled={disabled}
            aria-invalid={field.invalid || undefined}
            aria-describedby={field.describedBy}
            aria-label={label ? undefined : hint ?? '验证码'}
            onChange={(e: ChangeEvent<HTMLInputElement>) => onChange?.(onlyDigits(e.target.value, length))}
            className="peer absolute inset-0 z-10 size-full cursor-default opacity-0 disabled:cursor-not-allowed"
          />
          <div
            aria-hidden="true"
            className={`flex gap-2 outline-offset-4 peer-focus-visible:outline-2 peer-focus-visible:outline-[var(--color-accent)] ${disabled ? 'opacity-50' : ''}`}
          >
            {cells.map((i) => {
              const filled = i < value.length;
              const active = i === value.length && !disabled;
              return (
                <span
                  key={i}
                  className={`
                    grid place-items-center size-11 rounded-xl border border-transparent
                    bg-[var(--neuro-bg)] font-mono text-[length:var(--text-base)] tabular-nums
                    shadow-[inset_2px_2px_5px_var(--neuro-dark),inset_-2px_-2px_5px_var(--neuro-light)]
                    ${active ? 'peer-focus-visible:shadow-[inset_2px_2px_5px_var(--neuro-dark),inset_-2px_-2px_5px_var(--neuro-light),inset_0_0_0_2px_var(--color-accent)]' : ''}
                    ${field.invalid && filled ? 'text-[var(--color-warning-fg)]' : filled ? 'text-[var(--color-text)]' : 'text-[var(--color-text-subtle)]'}
                  `}
                >
                  {filled ? value[i] : ''}
                </span>
              );
            })}
          </div>
        </div>
      )}
    </Field>
  );
}

元信息

标识
otp/neumorphic
状态
done
最后提交
2026-11-01
风格轴
neumorphic
依赖 token
--color-accent --color-text --color-text-muted --color-text-subtle --color-warning-fg --glass-border --glass-recess --neuro-bg --neuro-dark --neuro-light --text-2xs --text-base --text-xs
在工作台打开 源码与 notes 都在 src/components/otp/neumorphic/