登录弹窗
毛玻璃登录弹窗:登录 / 注册 / 验证码三个视图平滑切换,6 位验证码输入与重发倒计时。
MioUI
温润、克制的登录弹窗
点击右上角按钮打开。登录密码 mioui,验证码 123456,其它输入会显示错误。
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { AuthModal } from '@/components/blocks/auth-modal';
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
export default function AuthModalDemo() {
const [open, setOpen] = useState(false);
return (
<div className="w-full">
{/* 示例顶栏 */}
<div className="flex h-12 items-center justify-between border-b border-hairline bg-surface-1 px-4">
<span className="font-title text-[16px] text-text-primary">MioUI</span>
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" onClick={() => setOpen(true)}>
登录
</Button>
<Button size="sm" onClick={() => setOpen(true)}>
免费开始
</Button>
</div>
</div>
<div className="flex min-h-[260px] flex-col items-center justify-center gap-2 px-6 py-10 text-center">
<p className="font-title text-[22px] text-text-primary">温润、克制的登录弹窗</p>
<p className="max-w-[420px] text-sm text-text-secondary">
点击右上角按钮打开。登录密码 <code className="font-mono">mioui</code>,验证码 <code className="font-mono">123456</code>,其它输入会显示错误。
</p>
</div>
<AuthModal
open={open}
onOpenChange={setOpen}
onLogin={async ({ password }) => {
await wait(900);
if (password !== 'mioui') throw new Error('邮箱或密码不正确。');
}}
onSignup={async () => {
await wait(900);
}}
onSendCode={async () => {
await wait(700);
}}
onVerify={async ({ code }) => {
await wait(800);
if (code !== '123456') throw new Error('验证码不正确或已过期。');
}}
/>
</div>
);
}
包含
- 基于
Dialog的毛玻璃面板:backdrop-blur+color-mix半透明 Surface + 发丝边,16px 圆角 Segmented切换 登录 / 注册 / 验证码,AnimatePresence横向滑入 + 容器高度 GENTLE 弹簧- 验证码视图:先填邮箱发送,再输入 6 个单格数字框(自动前进、退格回退、←→ 移动、整段粘贴)
- 重新发送倒计时(
tabular-nums),可更换邮箱 - 注册成功后自动进入验证码步骤
- 复用 OAuth 按钮、抖动错误横幅与 Turnstile 槽位;提交期间锁定表单并禁止关闭
用法
import { AuthModal } from "@/components/blocks/auth-modal";
const [open, setOpen] = useState(false);
<Button onClick={() => setOpen(true)}>登录</Button>
<AuthModal
open={open}
onOpenChange={setOpen}
onLogin={({ email, password }) => api.login(email, password)}
onSignup={({ name, email, password }) => api.signup(name, email, password)}
onSendCode={(email) => api.sendCode(email)}
onVerify={({ email, code }) => api.verify(email, code)} // 抛错 → 清空并抖动
/>OtpInput 也可单独使用:
import { OtpInput } from "@/components/blocks/auth-parts";
<OtpInput value={code} onChange={setCode} onComplete={verify} aria-label="验证码" digitLabel={(i) => `第 ${i} 位`} />源码
'use client';
import { AnimatePresence, motion } from 'motion/react';
import { ArrowLeft, CheckCircle, EnvelopeSimple } from '@phosphor-icons/react';
import { useEffect, useId, useRef, useState, type FormEvent, type ReactNode } from 'react';
import { twMerge } from 'tailwind-merge';
import { Button } from '@/components/ui/button';
import { Dialog } from '@/components/ui/dialog';
import { Field, Input, Label, PasswordInput } from '@/components/ui/input';
import { Segmented } from '@/components/ui/segmented';
import { Spinner } from '@/components/ui/feedback';
import { SPRING_BOUNCY, SPRING_GENTLE } from '@/lib/motion';
import { useReducedMotionPref } from '@/lib/use-reduced-motion';
import { OAuthButtons, OrDivider, OtpInput, ShakeError, TurnstileSlot, useCountdown, type OAuthProvider } from './auth-parts';
const COPY = {
zh: {
dialog: '登录 MioUI',
login: '登录',
signup: '注册',
otp: '验证码',
views: '登录方式',
loginTitle: '欢迎回来',
loginDesc: '用邮箱和密码登录',
signupTitle: '创建账户',
signupDesc: '几秒钟即可开始',
otpTitle: '验证码登录',
otpDesc: '无需密码,我们会发送一次性验证码',
codeTitle: '输入验证码',
codeDesc: (e: string) => `6 位验证码已发送至 ${e}`,
name: '名字',
namePh: '怎么称呼你',
email: '邮箱',
emailPh: 'you@example.com',
password: '密码',
passwordPh: '输入密码',
newPasswordPh: '至少 8 位',
show: '显示密码',
hide: '隐藏密码',
forgot: '忘记密码?',
submitLogin: '登录',
submitSignup: '创建账户',
sendCode: '发送验证码',
verify: '验证',
resend: '重新发送',
resendIn: (s: number) => `${s} 秒后可重新发送`,
changeEmail: '更换邮箱',
working: '处理中…',
or: '或',
code: '验证码',
digit: (i: number) => `第 ${i} 位`,
close: '关闭',
successTitle: '一切就绪',
successDesc: '你已成功登录。',
finish: '完成',
fallbackError: '出了点问题,请稍后再试。',
required: '请完整填写表单。',
},
en: {
dialog: 'Sign in to MioUI',
login: 'Sign in',
signup: 'Sign up',
otp: 'Code',
views: 'Sign-in method',
loginTitle: 'Welcome back',
loginDesc: 'Sign in with email and password',
signupTitle: 'Create an account',
signupDesc: 'Get started in seconds',
otpTitle: 'Sign in with a code',
otpDesc: 'No password needed — we will email you a one-time code',
codeTitle: 'Enter the code',
codeDesc: (e: string) => `We sent a 6-digit code to ${e}`,
name: 'Name',
namePh: 'What should we call you',
email: 'Email',
emailPh: 'you@example.com',
password: 'Password',
passwordPh: 'Enter your password',
newPasswordPh: 'At least 8 characters',
show: 'Show password',
hide: 'Hide password',
forgot: 'Forgot password?',
submitLogin: 'Sign in',
submitSignup: 'Create account',
sendCode: 'Send code',
verify: 'Verify',
resend: 'Resend code',
resendIn: (s: number) => `Resend in ${s}s`,
changeEmail: 'Use another email',
working: 'Working…',
or: 'or',
code: 'Verification code',
digit: (i: number) => `Digit ${i}`,
close: 'Close',
successTitle: 'You are all set',
successDesc: 'You are now signed in.',
finish: 'Done',
fallbackError: 'Something went wrong. Please try again.',
required: 'Please complete the form.',
},
};
export type AuthView = 'login' | 'signup' | 'otp';
export interface AuthModalProps {
open: boolean;
onOpenChange: (o: boolean) => void;
lang?: 'zh' | 'en';
defaultView?: AuthView;
/** 以下回调都可以返回 Promise;reject(Error) 时显示抖动错误 */
onLogin?: (v: { email: string; password: string }) => Promise<void> | void;
/** 注册成功后自动进入验证码步骤 */
onSignup?: (v: { name: string; email: string; password: string }) => Promise<void> | void;
onSendCode?: (email: string) => Promise<void> | void;
onVerify?: (v: { email: string; code: string }) => Promise<void> | void;
onOAuth?: (p: OAuthProvider) => void;
/** 真实的 Turnstile 组件;省略时显示占位框 */
turnstile?: ReactNode;
hideTurnstile?: boolean;
/** 重新发送冷却秒数,默认 60 */
resendSeconds?: number;
}
/** 随内容高度做 GENTLE 弹簧(视图切换时) */
function AutoHeight({ children }: { children: ReactNode }) {
const inner = useRef<HTMLDivElement>(null);
const [h, setH] = useState<number | 'auto'>('auto');
const reduce = useReducedMotionPref();
useEffect(() => {
const el = inner.current;
if (!el) return;
const ro = new ResizeObserver(() => setH(el.offsetHeight));
ro.observe(el);
return () => ro.disconnect();
}, []);
return (
<motion.div
animate={{ height: h }}
initial={false}
transition={reduce ? { duration: 0 } : SPRING_GENTLE}
// 负外边距 + 内边距:保证焦点环不被 overflow 裁掉
className="-mx-1.5 overflow-hidden px-1.5"
>
<div ref={inner} className="py-1.5">
{children}
</div>
</motion.div>
);
}
const run = async (fn?: () => Promise<void> | void) => {
await fn?.();
};
/** 毛玻璃登录 / 注册 / 验证码弹窗 */
export function AuthModal({
open,
onOpenChange,
lang = 'zh',
defaultView = 'login',
onLogin,
onSignup,
onSendCode,
onVerify,
onOAuth,
turnstile,
hideTurnstile,
resendSeconds = 60,
}: AuthModalProps) {
const t = COPY[lang];
const id = useId();
const reduce = useReducedMotionPref();
const [view, setView] = useState<AuthView>(defaultView);
const [dir, setDir] = useState(1);
const [step, setStep] = useState<'email' | 'code'>('email');
const [done, setDone] = useState(false);
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [code, setCode] = useState('');
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
const [shake, setShake] = useState(0);
const countdown = useCountdown();
// 关闭后重置
useEffect(() => {
if (open) return;
const tm = setTimeout(() => {
setView(defaultView);
setStep('email');
setDone(false);
setPassword('');
setCode('');
setError(null);
setPending(false);
countdown.reset();
}, 200);
return () => clearTimeout(tm);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
const order: AuthView[] = ['login', 'signup', 'otp'];
const go = (v: AuthView) => {
setDir(order.indexOf(v) >= order.indexOf(view) ? 1 : -1);
setView(v);
setError(null);
};
const fail = (err: unknown) => {
setError(err instanceof Error && err.message ? err.message : t.fallbackError);
setShake((n) => n + 1);
};
const guarded = async (fn: () => Promise<void>) => {
if (pending) return;
setError(null);
setPending(true);
try {
await fn();
} catch (e) {
fail(e);
} finally {
setPending(false);
}
};
const submitLogin = (e: FormEvent) => {
e.preventDefault();
if (!email.trim() || !password) return fail(new Error(t.required));
guarded(async () => {
await run(() => onLogin?.({ email: email.trim(), password }));
setDone(true);
});
};
const sendCode = (to = email.trim()) =>
guarded(async () => {
await run(() => onSendCode?.(to));
setCode('');
setStep('code');
countdown.start(resendSeconds);
});
const submitSignup = (e: FormEvent) => {
e.preventDefault();
if (!name.trim() || !email.trim() || password.length < 8) return fail(new Error(t.required));
guarded(async () => {
await run(() => onSignup?.({ name: name.trim(), email: email.trim(), password }));
await run(() => onSendCode?.(email.trim()));
setDir(1);
setView('otp');
setStep('code');
setCode('');
countdown.start(resendSeconds);
});
};
const submitOtpEmail = (e: FormEvent) => {
e.preventDefault();
if (!email.trim()) return fail(new Error(t.required));
sendCode();
};
const verify = (c = code) => {
if (c.length < 6) return;
guarded(async () => {
try {
await run(() => onVerify?.({ email: email.trim(), code: c }));
} catch (err) {
setCode('');
throw err;
}
setDone(true);
});
};
const slide = reduce
? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 }, transition: { duration: 0 } }
: {
initial: { opacity: 0, x: 16 * dir },
animate: { opacity: 1, x: 0 },
exit: { opacity: 0, x: -16 * dir, transition: { duration: 0.1 } },
transition: SPRING_GENTLE,
};
const viewKey = done ? 'done' : view === 'otp' ? `otp-${step}` : view;
const header = (title: string, desc: string) => (
<div className="mb-5 text-center">
<h2 className="font-title text-[22px] leading-tight text-text-primary">{title}</h2>
<p className="mt-1 text-[13px] text-text-secondary">{desc}</p>
</div>
);
const submitBtn = (label: string) => (
<Button type="submit" size="lg" className="w-full" aria-busy={pending || undefined}>
{pending && <Spinner size="sm" label={t.working} />}
{pending ? t.working : label}
</Button>
);
const emailField = (autoFocus?: boolean) => (
<Field>
<Label htmlFor={`${id}-email`}>{t.email}</Label>
<Input
id={`${id}-email`}
type="email"
size="lg"
autoComplete="email"
placeholder={t.emailPh}
value={email}
data-autofocus={autoFocus ? '' : undefined}
onChange={(e) => setEmail(e.target.value)}
/>
</Field>
);
const social = (
<>
<OrDivider>{t.or}</OrDivider>
<OAuthButtons onSelect={onOAuth} disabled={pending} />
</>
);
let body: ReactNode;
if (done) {
body = (
<div className="flex flex-col items-center py-4 text-center" aria-live="polite">
<motion.span
initial={reduce ? false : { scale: 0.4, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={reduce ? { duration: 0 } : SPRING_BOUNCY}
className="mb-4 inline-flex size-12 items-center justify-center rounded-full bg-success/12 text-success"
>
<CheckCircle size={26} weight="fill" />
</motion.span>
<h2 className="font-title text-[22px] leading-snug text-text-primary">{t.successTitle}</h2>
<p className="mt-1 text-sm text-text-secondary">{t.successDesc}</p>
<Button className="mt-6" variant="secondary" onClick={() => onOpenChange(false)}>
{t.finish}
</Button>
</div>
);
} else if (view === 'login') {
body = (
<form onSubmit={submitLogin} noValidate>
<fieldset disabled={pending} className="m-0 flex min-w-0 flex-col gap-4 border-0 p-0">
{header(t.loginTitle, t.loginDesc)}
<ShakeError message={error} shakeKey={shake} />
{emailField(true)}
<Field>
<div className="flex items-center justify-between">
<Label htmlFor={`${id}-pwd`}>{t.password}</Label>
<a href="#" className="cds-focus rounded-sm text-[12px] text-text-secondary transition-colors duration-[120ms] hover:text-accent-text">
{t.forgot}
</a>
</div>
<PasswordInput
id={`${id}-pwd`}
size="lg"
placeholder={t.passwordPh}
value={password}
disabled={pending}
invalid={!!error}
onChange={(e) => setPassword(e.target.value)}
showLabel={t.show}
hideLabel={t.hide}
/>
</Field>
{!hideTurnstile && <TurnstileSlot>{turnstile}</TurnstileSlot>}
{submitBtn(t.submitLogin)}
{social}
</fieldset>
</form>
);
} else if (view === 'signup') {
body = (
<form onSubmit={submitSignup} noValidate>
<fieldset disabled={pending} className="m-0 flex min-w-0 flex-col gap-4 border-0 p-0">
{header(t.signupTitle, t.signupDesc)}
<ShakeError message={error} shakeKey={shake} />
<Field>
<Label htmlFor={`${id}-name`}>{t.name}</Label>
<Input
id={`${id}-name`}
size="lg"
autoComplete="name"
placeholder={t.namePh}
value={name}
data-autofocus=""
onChange={(e) => setName(e.target.value)}
/>
</Field>
{emailField()}
<Field>
<Label htmlFor={`${id}-npwd`}>{t.password}</Label>
<PasswordInput
id={`${id}-npwd`}
size="lg"
autoComplete="new-password"
placeholder={t.newPasswordPh}
value={password}
disabled={pending}
onChange={(e) => setPassword(e.target.value)}
showLabel={t.show}
hideLabel={t.hide}
/>
</Field>
{!hideTurnstile && <TurnstileSlot>{turnstile}</TurnstileSlot>}
{submitBtn(t.submitSignup)}
{social}
</fieldset>
</form>
);
} else if (step === 'email') {
body = (
<form onSubmit={submitOtpEmail} noValidate>
<fieldset disabled={pending} className="m-0 flex min-w-0 flex-col gap-4 border-0 p-0">
{header(t.otpTitle, t.otpDesc)}
<ShakeError message={error} shakeKey={shake} />
{emailField(true)}
{!hideTurnstile && <TurnstileSlot>{turnstile}</TurnstileSlot>}
{submitBtn(t.sendCode)}
</fieldset>
</form>
);
} else {
body = (
<form
onSubmit={(e) => {
e.preventDefault();
verify();
}}
noValidate
>
<fieldset disabled={pending} className="m-0 flex min-w-0 flex-col gap-4 border-0 p-0">
<div className="mb-1 flex flex-col items-center text-center">
<span aria-hidden className="mb-3 inline-flex size-10 items-center justify-center rounded-full bg-accent-soft text-accent-text">
<EnvelopeSimple size={20} />
</span>
<h2 className="font-title text-[22px] leading-tight text-text-primary">{t.codeTitle}</h2>
<p className="mt-1 text-[13px] text-text-secondary">{t.codeDesc(email.trim())}</p>
</div>
<ShakeError message={error} shakeKey={shake} />
<OtpInput
value={code}
onChange={(v) => {
setCode(v);
if (error) setError(null);
}}
onComplete={(v) => verify(v)}
disabled={pending}
invalid={!!error}
autoFocus
aria-label={t.code}
digitLabel={t.digit}
/>
{submitBtn(t.verify)}
<div className="flex items-center justify-between text-[13px]">
<button
type="button"
onClick={() => {
setStep('email');
setError(null);
countdown.reset();
}}
className="cds-focus inline-flex items-center gap-1 rounded-sm text-text-secondary transition-colors duration-[120ms] hover:text-text-primary"
>
<ArrowLeft size={14} />
{t.changeEmail}
</button>
{countdown.left > 0 ? (
<span className="tabular-nums text-text-muted" aria-live="off">
{t.resendIn(countdown.left)}
</span>
) : (
<button
type="button"
onClick={() => sendCode()}
className="cds-focus rounded-sm font-medium text-accent-text underline-offset-4 hover:underline"
>
{t.resend}
</button>
)}
</div>
</fieldset>
</form>
);
}
return (
<Dialog
open={open}
onOpenChange={onOpenChange}
aria-label={t.dialog}
size="sm"
dismissible={!pending}
closeLabel={t.close}
className={twMerge(
'p-6 pt-5 ring-1 ring-hairline backdrop-blur-xl backdrop-saturate-150',
'bg-[color:color-mix(in_oklab,var(--cds-surface-2)_72%,transparent)]',
'dark:bg-[color:color-mix(in_oklab,var(--cds-surface-popover)_70%,transparent)]',
)}
>
{!done && (
<div className="mb-5 flex justify-center pr-0">
<Segmented<AuthView>
ariaLabel={t.views}
value={view}
onChange={go}
options={[
{ value: 'login', label: t.login },
{ value: 'signup', label: t.signup },
{ value: 'otp', label: t.otp },
]}
/>
</div>
)}
<AutoHeight>
<AnimatePresence mode="wait" initial={false}>
<motion.div key={viewKey} {...slide}>
{body}
</motion.div>
</AnimatePresence>
</AutoHeight>
</Dialog>
);
}