MioUI

Auth Modal

A glass-morphism auth modal with smooth Sign in / Sign up / Code views, a 6-digit OTP input and a resend countdown.

MioUI

A warm, restrained auth modal

Open it from the top bar. Password mioui, code 123456; anything else shows an error.

Includes

  • Glass panel built on Dialog: backdrop-blur + translucent surface via color-mix + hairline, 16px radius
  • Segmented switches Sign in / Sign up / Code, with AnimatePresence slides and a GENTLE height spring
  • Code view: enter email and send, then six single-digit boxes (auto-advance, backspace, ←→, paste the whole code)
  • Resend countdown (tabular-nums) and “use another email”
  • Sign-up flows straight into the code step
  • Reuses the OAuth buttons, shaking error banner and Turnstile slot; the form locks and the dialog can’t be dismissed while submitting

Usage

import { AuthModal } from "@/components/blocks/auth-modal";

const [open, setOpen] = useState(false);

<Button onClick={() => setOpen(true)}>Sign in</Button>
<AuthModal
  lang="en"
  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)}   // throw → clears and shakes
/>

OtpInput also works on its own:

import { OtpInput } from "@/components/blocks/auth-parts";

<OtpInput value={code} onChange={setCode} onComplete={verify} />

Source

components/blocks/auth-modal.tsx
'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>
  );
}