블록
가운데 로그인
패스키, 이메일 코드, 소셜 로그인을 단계별로 안내하는 로그인 화면이에요.
- #arc
- #block
- #login
- #auth
- #passkey
- #form
- #flow
1280px
설치
이 명령 한 줄이면 필요한 파일과 패키지가 함께 설치돼요.
터미널터미널터미널bash
npx shadcn@latest add https://dsui.vercel.app/r/login-centered.json파일
components/arc/blocks/login-centered/login-centered.module.csscomponents/arc/blocks/login-centered/login-centered.tsx
npm 패키지
@hugeicons/core-free-icons@^4.3.5@hugeicons/react@^1.1.10motion@^14.2.0
login-centered.module.csslogin-centered.module.csslogin-centered.module.csscss
/* The screen fills its container like a viewport. Bars share one height, so the stage center and the ring center stay the same point. */
.screen { --bar: 64px; container: login-centered / inline-size; position: relative; isolation: isolate; display: grid; grid-template-rows: var(--bar) minmax(0, 1fr) var(--bar); width: 100%; min-width: 0; min-height: 640px; justify-self: stretch; align-self: stretch; overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius-panel); background: var(--background); color: var(--foreground); font-family: var(--font-body); letter-spacing: var(--tracking-body); }
.fullScreen { min-height: 100dvh; border: 0; border-radius: 0; }
/* Quiet line backdrop. The mask only fades the outer rings into the canvas; nothing here is filled. */
.backdrop { position: absolute; inset: 0; z-index: -1; pointer-events: none; -webkit-mask-image: radial-gradient(ellipse 72% 78% at 50% 50%, #000 38%, transparent 100%); mask-image: radial-gradient(ellipse 72% 78% at 50% 50%, #000 38%, transparent 100%); }
.lines { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }
.ring, .axis { fill: none; stroke: var(--border-subtle); stroke-width: 1; }
.ring { transform: translate(calc(var(--dx, 0px) * var(--k, 1)), calc(var(--dy, 0px) * var(--k, 1))); }
.ringMajor { stroke: var(--border); }
.axis { transform: translate(var(--dx, 0px), var(--dy, 0px)); }
.bar, .foot { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: var(--space-4); padding: 0 var(--space-6); }
.mark { display: inline-flex; align-items: center; gap: var(--space-2); font-size: var(--text-base); font-weight: 500; line-height: 1; }
.switch { display: inline-flex; min-width: 0; align-items: center; gap: var(--space-1); margin: 0; color: var(--text-secondary); font-size: var(--text-sm); line-height: var(--leading-body); white-space: nowrap; }
.switchPrompt { position: relative; display: inline-grid; justify-items: end; }
.switchPrompt > span, .switchButton > span { grid-area: 1 / 1; }
.switchButton { position: relative; display: inline-grid; min-height: var(--control-height-sm); align-items: center; justify-items: center; border: 0; border-radius: var(--radius-pill); padding: 0 var(--space-3); background: none; color: var(--foreground); font: inherit; font-weight: 500; cursor: pointer; -webkit-tap-highlight-color: transparent; transition: background-color var(--duration-fast) var(--ease-standard); }
.switchButton:active { background: var(--surface-muted); }
.stage { display: grid; min-width: 0; place-items: center; padding: var(--space-6) var(--space-4); }
.card { width: min(100%, 420px); min-width: 0; overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius-surface); background: var(--surface); box-shadow: var(--shadow-resting); }
/* Clips the leaving step while the height glides; steps carry their own padding. */
.viewport { overflow: hidden; }
.track { position: relative; }
.step { display: grid; min-width: 0; padding: var(--space-8); }
.heading { display: grid; gap: var(--space-2); margin-bottom: var(--space-6); }
.heading h2 { margin: 0; font-family: var(--font-display); font-size: var(--text-3xl); font-weight: 400; letter-spacing: var(--tracking-display); line-height: var(--leading-display); text-wrap: balance; }
.heading p { margin: 0; color: var(--text-secondary); font-size: var(--text-sm); line-height: var(--leading-body); text-wrap: pretty; }
.address { color: var(--foreground); overflow-wrap: anywhere; }
.actions, .form { display: grid; gap: var(--space-3); }
.form { gap: var(--space-4); }
.wide { width: 100%; }
/* Rings leave the fingerprint while the device is asked. They sit in the label, so they take the button's text color. */
.pulse { position: relative; display: inline-grid; place-items: center; }
.pulseRing { position: absolute; inset: -2px; border: 1.5px solid currentColor; border-radius: var(--radius-pill); pointer-events: none; }
.divider { display: flex; align-items: center; gap: var(--space-3); margin: var(--space-5) 0; color: var(--text-muted); font-size: var(--text-xs); line-height: 1; }
.divider::before, .divider::after { flex: 1; height: 1px; background: var(--border); content: ""; }
.sso { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--space-2); }
.sso .ssoButton { min-width: 0; padding-inline: var(--space-2); }
.textButton { position: relative; display: inline-flex; min-height: var(--control-height-sm); align-items: center; justify-self: center; gap: var(--space-2); margin: var(--space-4) 0 calc(var(--space-2) * -1); border: 0; border-radius: var(--radius-pill); padding: 0 var(--space-3); background: none; color: var(--text-secondary); font: inherit; font-size: var(--text-sm); font-weight: 500; line-height: var(--leading-body); cursor: pointer; -webkit-tap-highlight-color: transparent; transition: color var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard); }
.textButton:active:not([aria-disabled="true"]) { color: var(--foreground); background: var(--surface-muted); }
.textButton[aria-disabled="true"] { color: var(--text-muted); font-weight: 400; cursor: default; }
.inlineButton { border: 0; border-radius: 4px; padding: 0; background: none; color: var(--foreground); font: inherit; font-weight: 500; text-decoration: underline; text-decoration-color: var(--border-strong); text-underline-offset: 3px; cursor: pointer; transition: text-decoration-color var(--duration-fast) var(--ease-standard); }
.resendLabel { display: inline-flex; align-items: baseline; gap: .3em; white-space: nowrap; }
.time { display: inline-flex; font-variant-numeric: tabular-nums; }
.timeColumn { position: relative; display: inline-flex; justify-content: center; }
.timeColumn > span { display: inline-block; }
.success { display: block; width: 48px; height: 48px; margin-bottom: var(--space-6); overflow: visible; color: var(--success); }
.details { display: grid; margin: calc(var(--space-2) * -1) 0 var(--space-6); border-top: 1px solid var(--border); padding: 0; }
.details div { display: flex; justify-content: space-between; gap: var(--space-4); border-bottom: 1px solid var(--border); padding: var(--space-3) 0; font-size: var(--text-sm); line-height: var(--leading-body); }
.details dt { color: var(--text-secondary); }
.details dd { margin: 0; text-align: right; }
.foot { color: var(--text-muted); font-size: var(--text-xs); line-height: var(--leading-body); }
.status { position: relative; display: block; min-width: 0; flex: 1; }
.status > span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.legal { display: flex; flex: none; gap: var(--space-1); }
.legal a { display: inline-flex; min-height: 32px; align-items: center; border-radius: var(--radius-pill); padding: 0 var(--space-2); color: var(--text-secondary); text-decoration: none; transition: color var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard); }
.legal a:active { color: var(--foreground); }
.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
@media (hover: hover) and (pointer: fine) {
.switchButton:hover { background: var(--surface-muted); }
.textButton:not([aria-disabled="true"]):hover { color: var(--foreground); background: var(--surface-muted); }
.inlineButton:hover { text-decoration-color: currentColor; }
.legal a:hover { color: var(--foreground); background: var(--surface-muted); }
}
@container login-centered (max-width: 520px) {
.bar, .foot { padding-inline: var(--space-4); }
.stage { padding: var(--space-4); }
.card { border-radius: var(--radius-panel); }
.step { padding: var(--space-6); }
}
@container login-centered (max-width: 400px) {
.stage { padding-inline: var(--space-3); }
.step { padding-inline: var(--space-5); }
.heading h2 { font-size: var(--text-2xl); }
.sso .ssoButton { gap: 6px; padding-inline: var(--space-1); font-size: var(--text-xs); }
.switchButton { padding-inline: var(--space-2); }
}
@media (prefers-reduced-motion: reduce) {
.switchButton, .textButton, .inlineButton, .legal a { transition: none; }
.ring, .axis { transform: none; }
}
/* The product mark takes the accent: the one spot of color before a person signs in. */
.mark svg { color: var(--accent); }
login-centered.tsxlogin-centered.tsxlogin-centered.tsxtsx
"use client";
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type FormEvent, type PointerEvent } from "react";
import { AnimatePresence, animate, motion, useMotionValue, useSpring, useTransform, type MotionStyle, type Transition, type Variants } from "motion/react";
import { useReducedMotion } from "../../lib/use-reduced-motion";
import { HugeiconsIcon } from "@hugeicons/react";
import { FingerPrintIcon } from "@hugeicons/core-free-icons";
import { Button } from "../../button/button";
import { Input } from "../../input/input";
import { OtpInput } from "../otp-input/otp-input";
import { motionTokens } from "../../lib/motion-tokens";
import styles from "./login-centered.module.css";
import { UiIcon } from "../../theme/ui-icon";
type Step = "passkey" | "email" | "code" | "done";
type Mode = "sign-in" | "sign-up";
type Provider = "Google" | "Apple" | "GitHub";
type PasskeyPhase = "idle" | "waiting" | "verified";
export type LoginMethod = "Passkey" | "Email code" | Provider;
export interface LoginCenteredProps {
/** The code the simulated email contains. */
demoCode?: string;
/** Fill the viewport (100dvh, no frame) when the block is the whole page. */
fullScreen?: boolean;
/** The account a simulated passkey or single sign-on resolves to. */
demoEmail?: string;
termsHref?: string;
privacyHref?: string;
/** Called once the simulated sign in or sign up succeeds. */
onSignIn?: (email: string, method: LoginMethod, mode: Mode) => void;
className?: string;
}
type StepCustom = { direction: number; reduce: boolean };
const RESEND_SECONDS = 30;
/** The official four-colour Google "G"; Apple and GitHub ship monochrome marks, drawn in currentColor. */
function GoogleLogo({ className }: { className?: string }) {
return <svg className={className} width="16" height="16" viewBox="0 0 24 24" aria-hidden="true">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" />
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" />
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" />
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" />
</svg>;
}
const providers: Provider[] = ["Google", "Apple", "GitHub"];
/** Monochrome provider marks in currentColor, so they stay neutral in both themes. */
const providerMarks: Record<Provider, string> = {
Google: "M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z",
Apple: "M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701",
GitHub: "M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12",
};
/** Concentric rings behind the card. Outer rings drift further, so the set reads as depth rather than a flat pattern. */
const rings = Array.from({ length: 14 }, (_, index) => ({ r: 224 + index * 60, k: 1 + index * .34, major: index % 3 === 2 }));
const isEmail = (value: string) => /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(value.trim());
/** Steps rise into place out of a small blur; going back, they settle down from above. */
const stepMotion: Variants = {
enter: ({ direction, reduce }: StepCustom) => reduce ? { opacity: 0 } : { opacity: 0, y: direction * 12, filter: `blur(${motionTokens.blur.soft}px)` },
center: ({ reduce }: StepCustom) => ({ opacity: 1, y: 0, filter: "blur(0px)", transition: reduce ? { duration: motionTokens.duration.instant } : { y: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.standard, ease: motionTokens.ease.enter, delay: motionTokens.delay.micro }, filter: { duration: motionTokens.duration.standard, ease: motionTokens.ease.enter, delay: motionTokens.delay.micro } } }),
exit: ({ direction, reduce }: StepCustom) => reduce ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, y: direction * -8, filter: `blur(${motionTokens.blur.soft}px)`, transition: { duration: motionTokens.duration.exit, ease: motionTokens.ease.standard } },
};
const swap = (reduce: boolean) => ({
initial: reduce ? { opacity: 0 } : { opacity: 0, y: 6, filter: `blur(${motionTokens.blur.soft}px)` },
animate: { opacity: 1, y: 0, filter: "blur(0px)" },
exit: reduce ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, y: -4, filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.fast } },
transition: (reduce ? { duration: motionTokens.duration.instant } : { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] }) as Transition,
});
/** Both header labels share one cell and cross-fade in place, so the switch never changes width when the mode flips. */
const labelState = (active: boolean, reduce: boolean) => active ? { opacity: 1, y: 0, filter: "blur(0px)" } : { opacity: 0, y: reduce ? 0 : -6, filter: `blur(${reduce ? 0 : motionTokens.blur.subtle}px)` };
const labelTransition = (reduce: boolean): Transition => reduce ? { duration: motionTokens.duration.instant } : { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] };
/** Seconds roll down while the timer runs and back up when a new code restarts it. */
const roll: Variants = {
enter: (direction: number) => ({ opacity: 0, y: `${-0.7 * direction}em`, filter: `blur(${motionTokens.blur.subtle}px)` }),
center: { opacity: 1, y: 0, filter: "blur(0px)" },
exit: (direction: number) => ({ opacity: 0, y: `${0.7 * direction}em`, filter: `blur(${motionTokens.blur.subtle}px)` }),
};
function RollingTime({ seconds, reduce }: { seconds: number; reduce: boolean }) {
const [shown, setShown] = useState({ seconds, direction: 1 });
if (shown.seconds !== seconds) setShown({ seconds, direction: seconds < shown.seconds ? 1 : -1 });
const text = `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`;
const transition: Transition = reduce ? { duration: 0 } : { y: motionTokens.spring.snappy, opacity: { duration: motionTokens.duration.fast }, filter: { duration: motionTokens.duration.fast } };
return <span className={styles.time}>{text.split("").map((character, index) => <span key={index} className={styles.timeColumn}>
<AnimatePresence initial={false} mode="popLayout" custom={shown.direction}><motion.span key={character} custom={shown.direction} variants={roll} initial="enter" animate="center" exit="exit" transition={transition}>{character}</motion.span></AnimatePresence>
</span>)}</span>;
}
/** Two rings leave the fingerprint while the device is asked, then stop on their own. */
function PasskeyPulse({ reduce }: { reduce: boolean }) {
return <span className={styles.pulse}>
{!reduce && [0, 1].map(index => <motion.span key={index} className={styles.pulseRing} initial={{ scale: .7, opacity: 0 }} animate={{ scale: [.7, 2.1], opacity: [.6, 0] }} transition={{ duration: 1.2, ease: [...motionTokens.ease.standard], delay: index * .6 }} />)}
<HugeiconsIcon icon={FingerPrintIcon} size={16} strokeWidth={1.75} aria-hidden="true" />
</span>;
}
/** The card follows its content on a spring only while the view changes; otherwise it stays auto, so field messages open without lag. */
function useViewHeight(view: string, reduce: boolean) {
const track = useRef<HTMLDivElement>(null);
const height = useMotionValue<number | "auto">("auto");
const measured = useRef(0);
const gliding = useRef(false);
const lastView = useRef(view);
const glide = useCallback((to: number) => {
gliding.current = true;
animate(height, to, { ...motionTokens.spring.smooth, onComplete: () => { gliding.current = false; height.jump("auto"); } });
}, [height]);
useEffect(() => {
const node = track.current;
if (!node || typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(([entry]) => {
if (!entry) return;
measured.current = entry.borderBoxSize?.[0]?.blockSize ?? node.offsetHeight;
if (gliding.current) glide(measured.current);
});
observer.observe(node);
return () => observer.disconnect();
}, [glide]);
useLayoutEffect(() => {
if (lastView.current === view) return;
lastView.current = view;
const current = height.get();
const from = typeof current === "number" ? current : measured.current;
const to = track.current?.offsetHeight ?? 0;
if (reduce || !from || !to) { gliding.current = false; height.jump("auto"); return; }
if (current === "auto") height.jump(from);
glide(to);
}, [view, reduce, height, glide]);
return { track, height };
}
/** Pointer drift for the line backdrop: fine pointers with hover only, never under reduced motion. */
function useDrift(reduce: boolean) {
const pointerX = useMotionValue(0);
const pointerY = useMotionValue(0);
const x = useSpring(pointerX, { visualDuration: .9, bounce: 0 });
const y = useSpring(pointerY, { visualDuration: .9, bounce: 0 });
const dx = useTransform(x, value => `${value.toFixed(2)}px`);
const dy = useTransform(y, value => `${value.toFixed(2)}px`);
const canHover = useRef(false);
useEffect(() => {
if (typeof window === "undefined" || !window.matchMedia) return;
const query = window.matchMedia("(hover: hover) and (pointer: fine)");
const sync = () => { canHover.current = query.matches; };
sync();
query.addEventListener("change", sync);
return () => query.removeEventListener("change", sync);
}, []);
const onPointerMove = (event: PointerEvent<HTMLElement>) => {
if (reduce || !canHover.current || event.pointerType !== "mouse") return;
const box = event.currentTarget.getBoundingClientRect();
pointerX.set(((event.clientX - box.left) / box.width - .5) * -2);
pointerY.set(((event.clientY - box.top) / box.height - .5) * -2);
};
const onPointerLeave = () => { pointerX.set(0); pointerY.set(0); };
return { style: { "--dx": dx, "--dy": dy } as MotionStyle, onPointerMove, onPointerLeave };
}
export function LoginCentered({ demoCode = "482913", fullScreen = false, demoEmail = "minjun@hanul.studio", termsHref = "#terms", privacyHref = "#privacy", onSignIn, className }: LoginCenteredProps) {
const reduce = !!useReducedMotion();
const [step, setStep] = useState<Step>("passkey");
const [mode, setMode] = useState<Mode>("sign-in");
const [direction, setDirection] = useState(1);
const [passkey, setPasskey] = useState<PasskeyPhase>("idle");
const [busy, setBusy] = useState<null | "email" | "code" | Provider>(null);
const [verified, setVerified] = useState(false);
const [email, setEmail] = useState("");
const [touched, setTouched] = useState(false);
const [attempted, setAttempted] = useState(false);
const [code, setCode] = useState("");
const [codeError, setCodeError] = useState("");
const [resendIn, setResendIn] = useState(RESEND_SECONDS);
const [account, setAccount] = useState(demoEmail);
const [method, setMethod] = useState<LoginMethod>("Passkey");
const [status, setStatus] = useState("");
const passkeyRef = useRef<HTMLButtonElement>(null);
const emailRef = useRef<HTMLInputElement>(null);
const codeRef = useRef<HTMLDivElement>(null);
const doneRef = useRef<HTMLHeadingElement>(null);
const focusNext = useRef<Step | null>(null);
const timers = useRef<number[]>([]);
const view = step === "email" ? `email-${mode}` : step;
const { track, height } = useViewHeight(view, reduce);
const drift = useDrift(reduce);
const emailError = (touched || attempted) && !isEmail(email) ? (email.trim() ? "name@example.com 같은 전체 주소를 입력해 주세요." : "이메일 주소를 입력해 주세요.") : "";
const custom: StepCustom = { direction, reduce };
const signingUp = mode === "sign-up";
useEffect(() => { const pending = timers; return () => pending.current.forEach(window.clearTimeout); }, []);
useEffect(() => {
if (step !== "code" || resendIn <= 0) return;
const timer = window.setTimeout(() => setResendIn(seconds => Math.max(0, seconds - 1)), 1000);
return () => window.clearTimeout(timer);
}, [step, resendIn]);
useEffect(() => {
const target = focusNext.current;
focusNext.current = null;
if (target === "passkey") passkeyRef.current?.focus();
if (target === "email") emailRef.current?.focus();
if (target === "done") doneRef.current?.focus();
}, [view]);
function later(run: () => void, ms: number) { timers.current.push(window.setTimeout(run, ms)); }
/** Any new direction cancels whatever the previous one was still waiting on. */
function settle() { timers.current.forEach(window.clearTimeout); timers.current = []; setBusy(null); setVerified(false); }
function go(next: Step, towards: number, focus: Step | null = next === "code" ? null : next) {
focusNext.current = focus;
setDirection(towards);
if (next === "passkey") setPasskey("idle");
setStep(next);
}
const focusCode = () => codeRef.current?.querySelector("input")?.focus();
function complete(address: string, how: LoginMethod) {
settle();
setAccount(address);
setMethod(how);
go("done", 1);
setStatus(`${how === "Email code" ? "이메일 코드" : how === "Passkey" ? "패스키" : how}로 ${signingUp ? "계정을 만들었어요" : "로그인했어요"}`);
onSignIn?.(address, how, mode);
}
function startPasskey() {
if (passkey !== "idle" || busy) return;
setPasskey("waiting");
setStatus("패스키를 기다리고 있어요");
later(() => {
setPasskey("verified");
setStatus("패스키를 확인했어요");
later(() => complete(demoEmail, "Passkey"), 640);
}, 1700);
}
function cancelPasskey() {
settle();
setPasskey("idle");
setStatus("패스키 요청을 취소했어요");
passkeyRef.current?.focus();
}
function switchToEmail() {
if (passkey === "verified" || busy) return;
settle();
setPasskey("idle");
setAttempted(false);
go("email", 1);
setStatus("로그인 코드를 이메일로 보내 드릴게요");
}
function signInWith(provider: Provider) {
if (busy || passkey !== "idle") return;
setBusy(provider);
setStatus(`${provider} 로그인 창을 여는 중이에요`);
later(() => complete(demoEmail, provider), 900);
}
function submitEmail(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (busy) return;
setAttempted(true);
if (!isEmail(email)) { emailRef.current?.focus(); return; }
setBusy("email");
setStatus("코드를 보내고 있어요");
later(() => {
const address = email.trim().toLowerCase();
setBusy(null);
setAccount(address);
setCode("");
setCodeError("");
setResendIn(RESEND_SECONDS);
go("code", 1);
setStatus(`${address} 주소로 코드를 보냈어요`);
}, 800);
}
function verify(value: string) {
if (busy || verified) return;
if (value.length < 6) { setCodeError("6자리를 모두 입력해 주세요."); focusCode(); return; }
setBusy("code");
setStatus("코드를 확인하고 있어요");
later(() => {
setBusy(null);
if (value !== demoCode) {
setCode("");
setCodeError("코드가 맞지 않아요. 가장 최근에 받은 이메일을 확인하고 다시 입력해 주세요.");
setStatus("코드가 맞지 않아요");
focusCode();
return;
}
setVerified(true);
setStatus("코드를 확인했어요");
later(() => complete(account, "Email code"), 600);
}, 700);
}
function changeCode(value: string) {
if (busy || verified) return;
setCode(value);
if (codeError && value) setCodeError("");
if (value.length === 6) verify(value);
}
function resend() {
if (busy || verified || resendIn > 0) return;
setResendIn(RESEND_SECONDS);
setCode("");
setCodeError("");
setStatus("새 코드를 보냈어요");
focusCode();
}
function changeEmail() {
settle();
setAttempted(false);
go("email", -1);
setStatus("이메일을 수정해 주세요");
}
function switchToPasskey() {
settle();
setMode("sign-in");
go("passkey", -1);
setStatus("패스키로 로그인해 주세요");
}
function switchMode() {
settle();
setAttempted(false);
setCode("");
if (signingUp) { setMode("sign-in"); go("passkey", -1); setStatus("계정에 로그인해 주세요"); return; }
setMode("sign-up");
go("email", 1);
setStatus("이메일로 계정을 만들어 주세요");
}
function signOut() {
settle();
setEmail("");
setTouched(false);
setAttempted(false);
setCode("");
setMode("sign-in");
go("passkey", -1);
setStatus("로그아웃했어요");
}
function openLegal(href: string, label: string) {
return (event: { preventDefault: () => void }) => {
if (!href.startsWith("#")) return;
event.preventDefault();
setStatus(`${label} 화면은 앱에서 열려요 (데모)`);
};
}
const passkeyLabel = passkey === "waiting" ? <><PasskeyPulse reduce={reduce} />기기를 기다리고 있어요</> : passkey === "verified" ? <><UiIcon iconRole="check" size={16} strokeWidth={2} aria-hidden="true" />패스키를 확인했어요</> : <><HugeiconsIcon icon={FingerPrintIcon} size={16} strokeWidth={1.75} aria-hidden="true" />패스키로 로그인하기</>;
return (
<section className={[styles.screen, fullScreen ? styles.fullScreen : "", className].filter(Boolean).join(" ")} aria-label={signingUp ? "계정 만들기" : "로그인"} onPointerMove={drift.onPointerMove} onPointerLeave={drift.onPointerLeave}>
<motion.div className={styles.backdrop} style={drift.style} aria-hidden="true">
<svg className={styles.lines}>
<svg x="50%" y="50%" overflow="visible">
<line className={styles.axis} x1="-2400" x2="2400" y1="0" y2="0" />
<line className={styles.axis} x1="0" x2="0" y1="-2400" y2="2400" />
{rings.map(ring => <circle key={ring.r} className={ring.major ? `${styles.ring} ${styles.ringMajor}` : styles.ring} r={ring.r} cx="0" cy="0" style={{ "--k": ring.k } as CSSProperties} />)}
</svg>
</svg>
</motion.div>
<header className={styles.bar}>
<span className={styles.mark}><svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true"><path d="M3 15a7 7 0 0 1 14 0" stroke="currentColor" strokeWidth="2" strokeLinecap="round" /><circle cx="10" cy="15" r="1.75" fill="currentColor" /></svg></span>
<p className={styles.switch}>
<span className={styles.switchPrompt}>{(["sign-in", "sign-up"] as const).map(option => <motion.span key={option} aria-hidden={option !== mode || undefined} initial={false} animate={labelState(option === mode, reduce)} transition={labelTransition(reduce)}>{option === "sign-up" ? "계정이 있나요?" : "계정이 없나요?"}</motion.span>)}</span>
<button type="button" className={styles.switchButton} aria-label={signingUp ? "로그인하기" : "회원가입하기"} onClick={switchMode}>{(["sign-in", "sign-up"] as const).map(option => <motion.span key={option} aria-hidden="true" initial={false} animate={labelState(option === mode, reduce)} transition={labelTransition(reduce)}>{option === "sign-up" ? "로그인하기" : "회원가입하기"}</motion.span>)}</button>
</p>
</header>
<div className={styles.stage}>
<div className={styles.card}>
<motion.div className={styles.viewport} style={{ height }}>
<div ref={track} className={styles.track}>
<AnimatePresence mode="popLayout" initial={false} custom={custom}>
{step === "passkey" && <motion.div key="passkey" className={styles.step} custom={custom} variants={stepMotion} initial="enter" animate="center" exit="exit">
<div className={styles.heading}><h2>로그인하기</h2><p>이 기기에 저장된 패스키를 사용해 주세요. 비밀번호는 필요 없어요.</p></div>
<div className={styles.actions}>
<Button ref={passkeyRef} type="button" className={styles.wide} aria-busy={passkey === "waiting" || undefined} aria-disabled={passkey !== "idle" || !!busy || undefined} onClick={startPasskey}>{passkeyLabel}</Button>
<Button type="button" variant="secondary" className={styles.wide} aria-disabled={passkey === "verified" || !!busy || undefined} onClick={passkey === "waiting" ? cancelPasskey : switchToEmail}>{passkey === "waiting" ? "취소하기" : <><UiIcon iconRole="mail" size={16} strokeWidth={1.75} aria-hidden="true" />이메일로 로그인하기</>}</Button>
</div>
<div className={styles.divider}>또는</div>
<div className={styles.sso} role="group" aria-label="간편 로그인">
{providers.map(provider => <Button key={provider} type="button" variant="secondary" className={styles.ssoButton} aria-label={`${provider}로 계속하기`} aria-disabled={passkey !== "idle" || (!!busy && busy !== provider) || undefined} loading={busy === provider} onClick={() => signInWith(provider)}>{provider === "Google" ? <GoogleLogo /> : <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d={providerMarks[provider]} /></svg>}{provider}</Button>)}
</div>
</motion.div>}
{step === "email" && <motion.div key={`email-${mode}`} className={styles.step} custom={custom} variants={stepMotion} initial="enter" animate="center" exit="exit">
<div className={styles.heading}>{signingUp ? <><h2>계정 만들기</h2><p>업무용 이메일을 입력해 주세요. 확인용 코드를 보내 드려요.</p></> : <><h2>이메일로 로그인하기</h2><p>받은편지함으로 6자리 코드를 보내 드려요.</p></>}</div>
<form className={styles.form} onSubmit={submitEmail} noValidate>
<Input ref={emailRef} label={signingUp ? "업무용 이메일" : "이메일"} type="email" name="email" inputMode="email" autoComplete="email" autoCapitalize="none" spellCheck={false} placeholder="name@example.com" value={email} readOnly={busy === "email"} error={emailError} onChange={event => setEmail(event.target.value)} onBlur={() => { if (email.trim()) setTouched(true); }} />
<Button type="submit" className={styles.wide} loading={busy === "email"}>{signingUp ? "계정 만들기" : "코드 보내기"}</Button>
</form>
{!signingUp && <button type="button" className={styles.textButton} onClick={switchToPasskey}><HugeiconsIcon icon={FingerPrintIcon} size={14} strokeWidth={1.75} aria-hidden="true" />패스키로 로그인하기</button>}
</motion.div>}
{step === "code" && <motion.div key="code" className={styles.step} custom={custom} variants={stepMotion} initial="enter" animate="center" exit="exit">
<div className={styles.heading}><h2>이메일을 확인해 주세요</h2><p><span className={styles.address}>{account}</span> 주소로 보낸 코드를 입력해 주세요. <button type="button" className={styles.inlineButton} onClick={changeEmail}>주소 바꾸기</button></p></div>
<form className={styles.form} onSubmit={event => { event.preventDefault(); verify(code); }} noValidate>
<div ref={codeRef}><OtpInput label="인증 코드" description={`데모 코드는 ${demoCode}예요.`} value={code} onChange={changeCode} error={codeError} autoFocus /></div>
<Button type="submit" className={styles.wide} loading={busy === "code"} aria-disabled={verified || undefined}>{verified ? <><UiIcon iconRole="check" size={16} strokeWidth={2} aria-hidden="true" />확인했어요</> : signingUp ? "확인하고 계정 만들기" : "확인하기"}</Button>
</form>
<button type="button" className={styles.textButton} aria-disabled={resendIn > 0 || verified || undefined} aria-label={resendIn > 0 ? `코드 다시 보내기, ${resendIn}초 뒤에 사용할 수 있어요` : "코드 다시 보내기"} onClick={resend}>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span key={resendIn > 0 ? "wait" : "ready"} className={styles.resendLabel} {...swap(reduce)}>{resendIn > 0 ? <>코드 다시 보내기 <RollingTime seconds={resendIn} reduce={reduce} /></> : "코드 다시 보내기"}</motion.span>
</AnimatePresence>
</button>
</motion.div>}
{step === "done" && <motion.div key="done" className={styles.step} custom={custom} variants={stepMotion} initial="enter" animate="center" exit="exit">
<svg className={styles.success} viewBox="0 0 48 48" aria-hidden="true">
<g transform="rotate(-90 24 24)"><motion.circle cx="24" cy="24" r="22.25" fill="none" stroke="currentColor" strokeWidth="1.5" initial={reduce ? false : { pathLength: 0, opacity: 0 }} animate={{ pathLength: 1, opacity: 1 }} transition={{ pathLength: { duration: motionTokens.duration.considered, ease: [...motionTokens.ease.inOut], delay: motionTokens.duration.fast }, opacity: { duration: motionTokens.duration.instant, delay: motionTokens.duration.fast } }} /></g>
<motion.path d="M15.5 24.5l5.5 5.5 11.5-12" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" initial={reduce ? false : { pathLength: 0, opacity: 0 }} animate={{ pathLength: 1, opacity: 1 }} transition={{ pathLength: { duration: motionTokens.duration.standard + .08, ease: [...motionTokens.ease.enter], delay: .52 }, opacity: { duration: motionTokens.duration.instant, delay: .52 } }} />
</svg>
<div className={styles.heading}><h2 ref={doneRef} tabIndex={-1}>{signingUp ? "환영해요" : "다시 만나서 반가워요"}</h2><p>{signingUp ? "계정이 준비됐어요. " : "다음 계정으로 로그인했어요. "}<span className={styles.address}>{account}</span></p></div>
<dl className={styles.details}>
<div><dt>로그인 방법</dt><dd>{method === "Email code" ? "이메일 코드" : method === "Passkey" ? "패스키" : method}</dd></div>
<div><dt>이 기기</dt><dd>30일 동안 기억해요</dd></div>
</dl>
<Button type="button" variant="secondary" className={styles.wide} onClick={signOut}>로그아웃하기</Button>
</motion.div>}
</AnimatePresence>
</div>
</motion.div>
</div>
</div>
<footer className={styles.foot}>
<p className={styles.srOnly} role="status" aria-live="polite">{status}</p>
<span className={styles.status} aria-hidden="true"><AnimatePresence mode="popLayout" initial={false}><motion.span key={status} {...swap(reduce)}>{status}</motion.span></AnimatePresence></span>
<nav className={styles.legal} aria-label="약관 및 정책">
<a href={termsHref} onClick={openLegal(termsHref, "이용약관")}>이용약관</a>
<a href={privacyHref} onClick={openLegal(privacyHref, "개인정보 처리방침")}>개인정보 처리방침</a>
</nav>
</footer>
</section>
);
}
export default LoginCentered;