otp · glass-apple · 灵感来源:Apple 短信验证码自动填充实践 / GOV.UK passwords
整段粘贴能一次填满,系统验证码自动填进来
错误与禁用
One-Time Code Input · 玻璃验证码输入 · 验证码 / Glass
做一个 One-Time Code Input(玻璃验证码输入):
- 一个真输入框 + 画的格子:逐格 input 会打断 autocomplete=one-time-code 自动填充
- 输入框 opacity-0 铺在格子上,格子 aria-hidden 只反映 value
- inputmode=numeric + pattern=[0-9]* + maxLength=格数,非数字在 onChange 里丢掉
- 焦点环画在格子组上(peer-focus-visible),待填格的 accent 挂在同一个焦点上,未聚焦不留 accent
- 错误走 aria-invalid + errorMessage,文案要说清几位、多久有效
用这些 CSS 变量:--color-accent --color-text --color-text-muted --color-text-subtle --color-warning-bd --color-warning-fg --glass-border --glass-inset --glass-recess --text-2xs --text-base --text-xs
灵感参考:Apple 短信验证码自动填充实践 / GOV.UK passwords--color-accent --color-text --color-text-muted --color-text-subtle --color-warning-bd --color-warning-fg --glass-border --glass-inset --glass-recess --text-2xs --text-base --text-xs
焦点 / 禁用 / 错误
/r/otp--glass-apple.json
免外壳的 markdown 版:/llms/components/otp--glass-apple.mdautocomplete="one-time-code")、粘贴要自己分发、
退格要跨格跳焦点、读屏逐格念。格子 aria-hidden,只反映 valueopacity-0 + inset-0):点哪都落在真输入框上,不需要 JS 抢焦点inputmode="numeric" + pattern="[0-9]*" + maxLength=格数;非数字在 onChange 里丢掉并截断peer-focus-visible);"填这一格"的 accent 描边也挂在同一个焦点上——
没聚焦时不许留着 accent,否则看起来像有两个焦点aria-invalid + errorMessage(role="alert"),提示文案要说清"几位、多久有效",
不要只写"格式错误"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);
/**
* 玻璃一次性验证码:一格一个 input 是常见做法,但它会打断系统级自动填充
* (iOS 只认单个 autocomplete="one-time-code" 的输入框),粘贴要自己分发,
* 退格要跨格跳焦点,读屏还会逐格念。所以这里用**一个真输入框 + 画的格子**:
* 输入框透明铺在格子上,格子只反映 value,aria-hidden。
*/
export function GlassAppleOtp({
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
font-mono text-[length:var(--text-base)] tabular-nums
bg-[var(--glass-recess)] border backdrop-blur-xl
shadow-[var(--glass-inset)]
${field.invalid
? 'border-[var(--color-warning-bd)]'
: 'border-[var(--glass-border)]'}
${active ? 'peer-focus-visible:border-[var(--color-accent)]' : ''}
${filled ? 'text-[var(--color-text)]' : 'text-[var(--color-text-subtle)]'}
`}
>
{filled ? value[i] : ''}
</span>
);
})}
</div>
</div>
)}
</Field>
);
}
src/components/otp/glass-apple/