MioUI

Auth Card

A centered, minimal sign-in card with serif title, email and password, loading lock, shake error, OAuth and a Turnstile slot.

Welcome back

Sign in to your MioUI account

No account yet? Sign up

Hint: use the password mioui to sign in; anything else shows an error.

Includes

  • Serif title and subtitle on a 12px-radius card
  • Email field, PasswordInput (eye toggle), remember me, forgot-password link
  • Submit turns into a Spinner + “Signing in…” during async submit and locks the whole form via <fieldset disabled>
  • Error banner: GENTLE height reveal plus a horizontal shake (skipped under reduced motion), role="alert"
  • GitHub / Google OAuth buttons with an “or” divider (borderless buttons inside the card — single-border discipline)
  • Cloudflare Turnstile placeholder (300×65 dashed box); pass the real widget via turnstile
  • Success state with a BOUNCY check pop-in

Usage

import { AuthCard } from "@/components/blocks/auth-card";

<AuthCard
  lang="en"
  onSubmit={async ({ email, password, remember }) => {
    const res = await signIn(email, password, remember);
    if (!res.ok) throw new Error("Incorrect email or password"); // throw → shaking error banner
  }}
  onOAuth={(provider) => startOAuth(provider)}                  // 'github' | 'google'
  forgotHref="/forgot"
  turnstile={<Turnstile siteKey="…" />}                        // placeholder when omitted
/>
PropTypeNotes
lang'zh' | 'en'Built-in copy language, default zh
onSubmit(v) => Promise<void>Reject shows the error, resolve shows success
onOAuth(p) => voidThird-party sign-in
turnstile / hideTurnstileReactNode / booleanBot-check slot

Shared parts (OAuthButtons, OrDivider, ShakeError, TurnstileSlot, OtpInput) live in components/blocks/auth-parts.tsx.

Source

components/blocks/auth-card.tsx
'use client';
import { AnimatePresence, motion } from 'motion/react';
import { ArrowLeft, CheckCircle } from '@phosphor-icons/react';
import { useId, useState, type FormEvent, type ReactNode } from 'react';
import { twMerge } from 'tailwind-merge';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { Field, Input, Label, PasswordInput } from '@/components/ui/input';
import { Spinner } from '@/components/ui/feedback';
import { SPRING_BOUNCY, SPRING_GENTLE } from '@/lib/motion';
import { useReducedMotionPref } from '@/lib/use-reduced-motion';
import { OAuthButtons, OrDivider, ShakeError, TurnstileSlot, type OAuthProvider } from './auth-parts';

const COPY = {
  zh: {
    title: '欢迎回来',
    subtitle: '登录你的 MioUI 账户',
    email: '邮箱',
    emailPh: 'you@example.com',
    password: '密码',
    passwordPh: '输入密码',
    show: '显示密码',
    hide: '隐藏密码',
    remember: '记住我',
    forgot: '忘记密码?',
    submit: '登录',
    submitting: '登录中…',
    or: '或',
    github: 'GitHub',
    google: 'Google',
    noAccount: '还没有账户?',
    signup: '注册',
    successTitle: '登录成功',
    successDesc: (e: string) => `已以 ${e} 登录。`,
    back: '返回',
    fallbackError: '登录失败,请稍后再试。',
    required: '请填写邮箱和密码。',
  },
  en: {
    title: 'Welcome back',
    subtitle: 'Sign in to your MioUI account',
    email: 'Email',
    emailPh: 'you@example.com',
    password: 'Password',
    passwordPh: 'Enter your password',
    show: 'Show password',
    hide: 'Hide password',
    remember: 'Remember me',
    forgot: 'Forgot password?',
    submit: 'Sign in',
    submitting: 'Signing in…',
    or: 'or',
    github: 'GitHub',
    google: 'Google',
    noAccount: 'No account yet?',
    signup: 'Sign up',
    successTitle: 'Signed in',
    successDesc: (e: string) => `You are signed in as ${e}.`,
    back: 'Back',
    fallbackError: 'Sign-in failed. Please try again.',
    required: 'Please enter your email and password.',
  },
};

export interface AuthCredentials {
  email: string;
  password: string;
  remember: boolean;
}

export interface AuthCardProps {
  lang?: 'zh' | 'en';
  /** 异步提交;reject(Error) 时显示抖动错误横幅,resolve 时进入成功态 */
  onSubmit?: (v: AuthCredentials) => Promise<void> | void;
  onOAuth?: (p: OAuthProvider) => void;
  forgotHref?: string;
  onForgot?: () => void;
  signupHref?: string;
  /** 真实的 Turnstile 组件;省略时显示 300×65 占位框 */
  turnstile?: ReactNode;
  /** 关闭 Turnstile 占位 */
  hideTurnstile?: boolean;
  className?: string;
}

/** 居中极简登录卡片:衬线标题、邮箱 + 密码、记住我、忘记密码、加载锁定、抖动错误、OAuth、Turnstile 槽位 */
export function AuthCard({
  lang = 'zh',
  onSubmit,
  onOAuth,
  forgotHref = '#',
  onForgot,
  signupHref = '#',
  turnstile,
  hideTurnstile,
  className,
}: AuthCardProps) {
  const t = COPY[lang];
  const id = useId();
  const reduce = useReducedMotionPref();
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [remember, setRemember] = useState(true);
  const [pending, setPending] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [shake, setShake] = useState(0);
  const [done, setDone] = useState(false);

  const fail = (msg: string) => {
    setError(msg);
    setShake((n) => n + 1);
  };

  const submit = async (e: FormEvent) => {
    e.preventDefault();
    if (pending) return;
    if (!email.trim() || !password) return fail(t.required);
    setError(null);
    setPending(true);
    try {
      await onSubmit?.({ email: email.trim(), password, remember });
      setPending(false);
      setDone(true);
    } catch (err) {
      setPending(false);
      fail(err instanceof Error && err.message ? err.message : t.fallbackError);
    }
  };

  const swap = reduce
    ? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 }, transition: { duration: 0 } }
    : { initial: { opacity: 0, y: 6 }, animate: { opacity: 1, y: 0 }, exit: { opacity: 0, y: -6, transition: { duration: 0.12 } }, transition: SPRING_GENTLE };

  return (
    <Card className={twMerge('w-full max-w-[380px] p-7', className)}>
      <AnimatePresence mode="wait" initial={false}>
        {done ? (
          <motion.div key="done" {...swap} className="flex flex-col items-center py-6 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.5 text-sm text-text-secondary">{t.successDesc(email.trim())}</p>
            <Button
              variant="ghost"
              size="sm"
              className="mt-6"
              onClick={() => {
                setDone(false);
                setPassword('');
              }}
            >
              <ArrowLeft />
              {t.back}
            </Button>
          </motion.div>
        ) : (
          <motion.form key="form" {...swap} onSubmit={submit} noValidate aria-busy={pending || undefined}>
            <fieldset disabled={pending} className="m-0 min-w-0 border-0 p-0">
              <div className="mb-6 text-center">
                <h2 className="font-title text-[26px] leading-tight text-text-primary">{t.title}</h2>
                <p className="mt-1.5 text-sm text-text-secondary">{t.subtitle}</p>
              </div>

              <div className="flex flex-col gap-4">
                <ShakeError message={error} shakeKey={shake} />

                <Field>
                  <Label htmlFor={`${id}-email`}>{t.email}</Label>
                  <Input
                    id={`${id}-email`}
                    type="email"
                    size="lg"
                    autoComplete="email"
                    placeholder={t.emailPh}
                    value={email}
                    onChange={(e) => setEmail(e.target.value)}
                    invalid={!!error && !email.trim()}
                  />
                </Field>

                <Field>
                  <div className="flex items-center justify-between">
                    <Label htmlFor={`${id}-pwd`}>{t.password}</Label>
                    <a
                      href={forgotHref}
                      onClick={(e) => {
                        if (onForgot) {
                          e.preventDefault();
                          onForgot();
                        }
                      }}
                      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}
                    onChange={(e) => setPassword(e.target.value)}
                    showLabel={t.show}
                    hideLabel={t.hide}
                    invalid={!!error}
                    disabled={pending}
                  />
                </Field>

                <label className="inline-flex w-fit cursor-pointer select-none items-center gap-2 text-[13px] text-text-secondary">
                  <input
                    type="checkbox"
                    checked={remember}
                    onChange={(e) => setRemember(e.target.checked)}
                    className="cds-focus size-4 rounded-sm accent-accent-brand"
                  />
                  {t.remember}
                </label>

                {!hideTurnstile && <TurnstileSlot>{turnstile}</TurnstileSlot>}

                <Button type="submit" size="lg" className="w-full" aria-busy={pending || undefined}>
                  {pending && <Spinner size="sm" label={t.submitting} />}
                  {pending ? t.submitting : t.submit}
                </Button>

                <OrDivider>{t.or}</OrDivider>

                <OAuthButtons onSelect={onOAuth} disabled={pending} githubLabel={t.github} googleLabel={t.google} />
              </div>

              <p className="mt-6 text-center text-[13px] text-text-secondary">
                {t.noAccount}{' '}
                <a href={signupHref} className="cds-focus rounded-sm font-medium text-accent-text underline-offset-4 hover:underline">
                  {t.signup}
                </a>
              </p>
            </fieldset>
          </motion.form>
        )}
      </AnimatePresence>
    </Card>
  );
}
components/blocks/auth-parts.tsx
'use client';
import { AnimatePresence, motion } from 'motion/react';
import { GithubLogo, GoogleLogo, WarningCircle } from '@phosphor-icons/react';
import {
  useEffect,
  useRef,
  useState,
  type ClipboardEvent,
  type KeyboardEvent,
  type ReactNode,
} from 'react';
import { twMerge } from 'tailwind-merge';
import { Button } from '@/components/ui/button';
import { SPRING_GENTLE, SPRING_SNAPPY } from '@/lib/motion';
import { useReducedMotionPref } from '@/lib/use-reduced-motion';

/* ==========================================================================
   认证区块共享零件:OAuthButtons / OrDivider / ShakeError / TurnstileSlot / OtpInput
   ========================================================================== */

export type OAuthProvider = 'github' | 'google';

/** 第三方登录按钮:卡片内部用 secondary(无边框,单线框纪律) */
export function OAuthButtons({
  onSelect,
  disabled,
  githubLabel = 'GitHub',
  googleLabel = 'Google',
  className,
}: {
  onSelect?: (p: OAuthProvider) => void;
  disabled?: boolean;
  githubLabel?: ReactNode;
  googleLabel?: ReactNode;
  className?: string;
}) {
  return (
    <div className={twMerge('grid grid-cols-2 gap-2', className)}>
      <Button type="button" variant="secondary" size="lg" className="text-sm" disabled={disabled} onClick={() => onSelect?.('github')}>
        <GithubLogo weight="fill" />
        {githubLabel}
      </Button>
      <Button type="button" variant="secondary" size="lg" className="text-sm" disabled={disabled} onClick={() => onSelect?.('google')}>
        <GoogleLogo weight="bold" />
        {googleLabel}
      </Button>
    </div>
  );
}

/** “或” 分隔线:两侧发丝线 */
export function OrDivider({ children = 'or', className }: { children?: ReactNode; className?: string }) {
  return (
    <div role="separator" className={twMerge('flex items-center gap-3 text-[12px] text-text-muted', className)}>
      <span className="h-px flex-1 bg-hairline" />
      <span>{children}</span>
      <span className="h-px flex-1 bg-hairline" />
    </div>
  );
}

/**
 * 错误横幅:出现时 GENTLE 高度展开;每次 shakeKey 变化左右抖动一次(减弱动效时不抖)。
 * role="alert" 读屏即时播报。message 为空时收起。
 */
export function ShakeError({ message, shakeKey, className }: { message?: ReactNode; shakeKey?: number | string; className?: string }) {
  const reduce = useReducedMotionPref();
  return (
    <AnimatePresence initial={false}>
      {message ? (
        <motion.div
          key="err"
          initial={reduce ? { opacity: 0 } : { opacity: 0, height: 0 }}
          animate={reduce ? { opacity: 1 } : { opacity: 1, height: 'auto' }}
          exit={reduce ? { opacity: 0 } : { opacity: 0, height: 0 }}
          transition={reduce ? { duration: 0 } : SPRING_GENTLE}
          className="overflow-hidden"
        >
          <motion.div
            key={shakeKey}
            role="alert"
            initial={reduce ? false : { x: 0 }}
            animate={reduce ? undefined : { x: [0, -7, 7, -5, 5, -2, 0] }}
            transition={{ duration: 0.42, ease: [0.16, 1, 0.3, 1] }}
            className={twMerge(
              'flex items-start gap-2 rounded-lg bg-danger/10 px-3 py-2 text-[13px] leading-snug text-danger [&_svg]:mt-px [&_svg]:size-4 [&_svg]:shrink-0',
              className,
            )}
          >
            <WarningCircle weight="fill" />
            <span className="min-w-0 flex-1">{message}</span>
          </motion.div>
        </motion.div>
      ) : null}
    </AnimatePresence>
  );
}

/**
 * Cloudflare Turnstile 占位:300×65 虚线发丝框。传 children 时直接渲染真实组件。
 */
export function TurnstileSlot({ children, label = 'Cloudflare Turnstile', className }: { children?: ReactNode; label?: ReactNode; className?: string }) {
  if (children) return <div className={twMerge('flex justify-center', className)}>{children}</div>;
  return (
    <div className={twMerge('flex justify-center', className)}>
      <div
        aria-hidden
        className="flex h-[65px] w-full max-w-[300px] items-center justify-center rounded-lg border border-dashed border-hairline-strong text-[12px] text-text-muted"
      >
        {label}
      </div>
    </div>
  );
}

/**
 * 6 位验证码输入:自动前进、退格回退、←→ 移动、整段粘贴、仅数字。
 */
export function OtpInput({
  value,
  onChange,
  onComplete,
  length = 6,
  disabled,
  invalid,
  autoFocus,
  'aria-label': ariaLabel = 'Verification code',
  digitLabel = (i: number) => `Digit ${i}`,
  className,
}: {
  value: string;
  onChange: (v: string) => void;
  onComplete?: (v: string) => void;
  length?: number;
  disabled?: boolean;
  invalid?: boolean;
  autoFocus?: boolean;
  'aria-label'?: string;
  digitLabel?: (i: number) => string;
  className?: string;
}) {
  const refs = useRef<(HTMLInputElement | null)[]>([]);
  const reduce = useReducedMotionPref();
  const chars = Array.from({ length }, (_, i) => value[i] ?? '');
  // 最新值(commit 后立即更新),供 onFocus 在父组件重渲染前判断
  const latest = useRef(value);
  latest.current = value;

  useEffect(() => {
    if (!autoFocus) return;
    const id = requestAnimationFrame(() => refs.current[Math.min(value.length, length - 1)]?.focus());
    return () => cancelAnimationFrame(id);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [autoFocus]);

  const focusAt = (i: number) => {
    const el = refs.current[Math.max(0, Math.min(length - 1, i))];
    el?.focus();
    el?.select();
  };

  const commit = (next: string) => {
    const v = next.replace(/\D/g, '').slice(0, length);
    latest.current = v;
    onChange(v);
    if (v.length === length) onComplete?.(v);
  };

  const setAt = (i: number, d: string) => {
    const arr = chars.slice();
    arr[i] = d;
    // 不允许中间留空:截断到第一个空位
    const joined = arr.join('');
    commit(joined);
  };

  const onKey = (i: number) => (e: KeyboardEvent<HTMLInputElement>) => {
    if (e.nativeEvent.isComposing || e.keyCode === 229) return;
    if (e.key === 'Backspace') {
      e.preventDefault();
      if (chars[i]) {
        commit(value.slice(0, i) + value.slice(i + 1));
        focusAt(i);
      } else if (i > 0) {
        commit(value.slice(0, i - 1) + value.slice(i));
        focusAt(i - 1);
      }
    } else if (e.key === 'ArrowLeft') {
      e.preventDefault();
      focusAt(i - 1);
    } else if (e.key === 'ArrowRight') {
      e.preventDefault();
      focusAt(Math.min(i + 1, value.length));
    } else if (/^\d$/.test(e.key)) {
      e.preventDefault();
      const pos = Math.min(i, value.length);
      if (pos < value.length) setAt(pos, e.key);
      else commit(value + e.key);
      focusAt(pos + 1);
    } else if (e.key.length === 1 && !e.metaKey && !e.ctrlKey) {
      e.preventDefault();
    }
  };

  const onPaste = (e: ClipboardEvent<HTMLInputElement>) => {
    const digits = e.clipboardData.getData('text').replace(/\D/g, '');
    if (!digits) return;
    e.preventDefault();
    commit(digits);
    focusAt(Math.min(digits.length, length - 1));
  };

  return (
    <div role="group" aria-label={ariaLabel} className={twMerge('flex justify-center gap-2', className)}>
      {chars.map((c, i) => {
        const filled = c !== '';
        return (
          <div key={i} className="relative">
            <input
              ref={(el) => {
                refs.current[i] = el;
              }}
              value={c}
              inputMode="numeric"
              autoComplete={i === 0 ? 'one-time-code' : 'off'}
              maxLength={1}
              disabled={disabled}
              aria-label={digitLabel(i + 1)}
              aria-invalid={invalid || undefined}
              onKeyDown={onKey(i)}
              onPaste={onPaste}
              // 移动端软键盘 / 自动填充可能绕过 keydown,这里兜底
              onChange={(e) => {
                const d = e.target.value.replace(/\D/g, '');
                if (!d) return;
                if (d.length > 1) {
                  commit(value.slice(0, i) + d);
                  focusAt(i + d.length);
                } else {
                  setAt(Math.min(i, value.length), d);
                  focusAt(i + 1);
                }
              }}
              onFocus={(e) => {
                // 只允许落在第一个空位或已填位置
                if (i > latest.current.length) focusAt(latest.current.length);
                else e.currentTarget.select();
              }}
              className={twMerge(
                'cds-focus size-11 rounded-lg bg-surface-3 text-center font-mono text-[18px] tabular-nums text-text-primary caret-accent-brand outline-none',
                'shadow-[inset_0_0_0_1px_var(--cds-border)] transition-[box-shadow,background-color] duration-[120ms] ease-cds hover:shadow-[inset_0_0_0_1px_var(--cds-border-strong)]',
                'focus:shadow-focus disabled:opacity-50',
                invalid && 'shadow-[inset_0_0_0_1px_var(--cds-danger)] hover:shadow-[inset_0_0_0_1px_var(--cds-danger)]',
              )}
            />
            {filled && !reduce && (
              <motion.span
                key={c + i}
                aria-hidden
                initial={{ scale: 0.6, opacity: 0.5 }}
                animate={{ scale: 1, opacity: 0 }}
                transition={SPRING_SNAPPY}
                className="pointer-events-none absolute inset-0 rounded-lg shadow-[inset_0_0_0_1.5px_var(--cds-accent)]"
              />
            )}
          </div>
        );
      })}
    </div>
  );
}

/** 重新发送倒计时:start(60) 开始,left 为剩余秒数(0 表示可重发) */
export function useCountdown() {
  const [left, setLeft] = useState(0);
  useEffect(() => {
    if (left <= 0) return;
    const id = setTimeout(() => setLeft((n) => n - 1), 1000);
    return () => clearTimeout(id);
  }, [left]);
  return { left, start: (s: number) => setLeft(s), reset: () => setLeft(0) };
}

On this page