Components

Getting started

Components109

More

Auth card

Sign-in that asks one thing at a time: the email field shrinks into a chip round the address, and your server decides whether a password, a code or a link comes next.

Used forSign-in and sign-up pagesPasswordless loginSocial loginInvite links

Install with the shadcn CLI

$ npx shadcn@latest add https://hairlineui.com/r/auth-card.json

Where it’s used

  • Sign-in and sign-up pages

    One card for passwords, one-time codes and magic links, asked one step at a time.

  • Passwordless login

    Email a code or a link as the main way in, with no password field to fill.

  • Social login

    Google or GitHub buttons above the email field, two to a row.

  • Invite links

    Open with the invited address already in the field.

What it handles for you

Most sign-in forms show every field at once or swap whole screens with a flash in between. This one asks for one thing at a time, and your server decides whether a password, a code or a link comes next.

Instead of wiring upa multi-step formstep transitionsa one-time code inputa resend timerprovider and passkey buttons
  1. Your server picks the next step

    onEmail answers “password”, “code” or “link” for each address, and the card moves on.

  2. The email becomes a chip

    The field shrinks round the address, and Change takes you back to edit it.

  3. Codes check themselves

    The code is sent for checking as the last digit lands. A wrong one shakes and clears, and Resend counts down.

  4. It opens the inbox for you

    A link step offers “Open Gmail”, and the same for Outlook, iCloud, Yahoo and Proton addresses.

  5. Providers and passkeys

    Provider buttons sit above the email field, and a passkey button appears when you pass onPasskey.

  6. Any auth service fits

    The card never calls a service or stores a password. Every step is one async function you connect.

Overview

Most sign-in forms show every field at once, or swap whole screens with a flash between them. This one asks one thing at a time, the way identifier-first sign-in works at Google and Clerk: providers, then your email, and your server says what comes next (a password, a code or a link). The email field doesn't go anywhere. Its frame shrinks round the address into a chip as the providers fold away above it, and "Change" opens at its end to take you back. Each next step slides in from the right while the card eases to its new height, and titles lift out and in. Codes are checked as the last digit lands. A link step has a button that opens the inbox itself for Gmail, Outlook, iCloud, Yahoo and Proton addresses. Done draws a ring and a check. The card never calls a service: every step is one async function.

States

email
Providers (two to a row), "or", then the email field and Continue. An address that isn't one shakes the field and says so under it; a passkey button sits under Continue when onPasskey is set.
checking
Continue morphs to "Checking" with a spinner while onEmail runs. If it throws, the field shakes ±4px, its hairline turns red and the message opens under it.
identity
The field's frame eases from full width and 40px to the address's own width and 32px, its corners to a pill, in 520ms; the address stays where it was typed. The providers fold up above it in 460ms and "Change" opens at its end 160ms later.
next step
The new step slides in 28px from the right out of a 4px blur (440ms) while the old one leaves the other way; the card eases to its new height. Change runs it backwards and selects the address.
password
A password field with a show/hide eye that swaps through a blur. A wrong password shakes the field, selects it and says why. "Forgot password?" morphs to "Sending a link" and moves on to Check your inbox.
code
The code boxes (code input): digits rise in, the row sweeps while onCode runs, a wrong code shakes and clears, a right one melts into one pill; then the card moves on. Resend counts down.
link
"Check your inbox", with "Open Gmail" (or Outlook, iCloud Mail, Yahoo Mail, Proton Mail) for addresses it knows, and "Resend in 0:28" rolling down to Resend.
providers
The pressed provider's mark gives way to a spinner while the others dim. If onProvider throws, the reason opens under the field; if it resolves (a popup), the card moves to done.
done
A ring draws itself in 640ms, then a check inside it; "Signed in". onSuccess runs 1.3s later, e.g. to redirect.

Usage

usage.tsx
import { AuthCard } from "@/components/auth-card";<AuthCard  onEmail={async (email) => (await hasPassword(email) ? "password" : "code")}  onPassword={(email, password) => signIn(email, password)}  onCode={(email, code) => verify(email, code)}  onSuccess={() => router.push("/app")}/>

Depends on lucide-react, installed for you by the CLI along with the keyframes it needs.

Works with

With Better Auth: password or a one-time code

A route on your server says which step an address needs. A one-time code goes out through the email OTP plugin; providers use social sign-in, which redirects. Any thrown error's message is what the card shows. Clerk's custom flows, Supabase Auth and Auth.js fit the same four functions.

example.tsx
"use client";import { useRouter } from "next/navigation";import { AuthCard } from "@/components/auth-card";import { authClient } from "@/lib/auth-client";import { GoogleIcon } from "@/components/icons";const check = <T,>(r: { error: { message?: string } | null; data: T }) => {  if (r.error) throw new Error(r.error.message ?? "That didn't work.");  return r.data;};export function SignIn() {  const router = useRouter();  return (    <AuthCard      providers={[{ id: "google", label: "Google", icon: <GoogleIcon /> }]}      onProvider={(provider) => authClient.signIn.social({ provider, callbackURL: "/app" })}      onPasskey={async () => check(await authClient.signIn.passkey())}      onEmail={async (email) => {        const res = await fetch("/api/auth/next-step", { method: "POST", body: JSON.stringify({ email }) });        const { step } = await res.json(); // "password" | "code"        if (step === "code") check(await authClient.emailOtp.sendVerificationOtp({ email, type: "sign-in" }));        return step;      }}      onPassword={async (email, password) => check(await authClient.signIn.email({ email, password }))}      onCode={async (email, otp) => check(await authClient.signIn.emailOtp({ email, otp }))}      onResend={async (email) => check(await authClient.emailOtp.sendVerificationOtp({ email, type: "sign-in" }))}      onForgot={async (email) => check(await authClient.requestPasswordReset({ email, redirectTo: "/reset" }))}      onSuccess={() => router.push("/app")}      footer={<>No account yet? <a href="/sign-up">Create one</a></>}    />  );}

Props

PropTypeDescription
onEmail(email) => Promise<"password" | "code" | "link">Asks your server what this address needs next (and sends the code or link if that's it). Throw to keep the field and show the message.
onPassword(email, password) => Promise<unknown>Signs in with a password. Throw when it's wrong.
onCode(email, code) => Promise<unknown>Checks a one-time code. Resolve when it's right; throw when it isn't.
onResend(email) => Promise<unknown>Sends the code or link again. Without it there is no resend.
onForgot(email) => Promise<unknown>Sends a password reset link; shows "Forgot password?" on the password step.
providers{ id, label, icon? }[]Sign-in providers above the email field, two to a row.
onProvider(id) => Promise<unknown>Starts a provider's sign-in. A redirect keeps its button spinning; a popup that resolves moves the card to done.
onPasskey() => Promise<unknown>Shows "Sign in with a passkey" and sets the email field's autocomplete to "username webauthn" for passkey autofill.
onSuccess() => voidRuns after "Signed in" has shown, e.g. to redirect.
mode"sign-in" | "sign-up"default "sign-in"Sets the default words and the password field's autocomplete.
copyPartial<Record<step, { title?, description? }>>Words per step: email, password, code, link, reset and done.
logoReactNodeAbove the title.
footerReactNodeUnder the email step: a link to sign up, terms. It folds away after the first step.
defaultEmailstringStarts the field with an address, e.g. from an invite link.
codeLengthnumberdefault 6Digits in a one-time code.
resendAfternumberdefault 30Seconds before a code or link can be sent again.

Accessibility and motion

  • It is one form: Enter continues from the email field and signs in from the password field. The address is a real email input (autocomplete="email", or "username webauthn" with passkeys) and stays one, read-only, while it's a chip; Change is a button at its end.
  • Each step moves focus to what comes next: the password field, the first code box, or the Open inbox button. Change returns focus to the address and selects it. Every new step's title is announced in a status region.
  • Errors are linked to their field with aria-describedby and the field is marked aria-invalid. The resend countdown is hidden from screen readers; the button says when it can be used.
  • The card never stores the password or code and never calls a service itself.
  • Inputs use 16px type on touch screens so phones don't zoom into them.
  • Reduced motion changes steps in place: no shrinking frame, slides, shakes or drawing; the words still say what happened.

Requirements

React 19, Tailwind CSS v4 and shadcn/ui theme variables (any style, light and dark). Icons from lucide-react.

The CLI also installs Code input, Text morph, Number roll, Status button.

Source

components/auth-card.tsx
"use client";import * as React from "react";import { ArrowUpRight, Eye, EyeOff, KeyRound } from "lucide-react";import { CodeInput } from "./code-input";import { NumberRoll } from "./number-roll";import { StatusButton, type ActionStatus } from "./status-button";import { TextMorph } from "./text-morph";/* ───────────────────────────────────────────────────────── * AUTH CARD: sign-in that asks one thing at a time * *   email     sign-in providers, then the email field; your *             server decides what comes next (a password, a *             code or a link), the way identifier-first sign-in *             works at Google and Clerk *   identity  the email field doesn't go anywhere: its frame *             shrinks round the address into a chip as the *             providers fold away above it, and "Change" opens *             at its end to take you back *   step      each next step slides in from the right (Change *             slides back from the left) while the card eases *             to its new height; titles lift out and in *   code      the code boxes (code input) check as the last *             digit lands, and resend counts down *   link      "Check your inbox", with a button that opens the *             inbox itself for Gmail, Outlook, iCloud, Yahoo *             and Proton addresses *   done      a ring draws, a check draws inside it, "Signed in" * * The card never calls a service: every step is one async * function, so it fits Better Auth, Clerk, Supabase or Auth.js. * ───────────────────────────────────────────────────────── */export type AuthNext = "password" | "code" | "link";export type AuthStep = "email" | AuthNext | "reset" | "done";export interface AuthProvider {  id: string;  label: string;  icon?: React.ReactNode;}export interface AuthCardProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "title"> {  mode?: "sign-in" | "sign-up";  /** Above the title: your mark. */  logo?: React.ReactNode;  /** Words per step: { email: { title, description }, code: { … }, … }. */  copy?: Partial<Record<AuthStep, { title?: string; description?: string }>>;  providers?: AuthProvider[];  /** Starts a provider's sign-in; usually it redirects, so the button keeps spinning. */  onProvider?: (id: string) => Promise<unknown> | unknown;  /** Shows "Sign in with a passkey". Resolve when signed in. */  onPasskey?: () => Promise<unknown>;  /** Your server says what comes next for this address. Throw to say why not. */  onEmail: (email: string) => Promise<AuthNext> | AuthNext;  onPassword?: (email: string, password: string) => Promise<unknown>;  /** Resolve when the code is right; throw when it isn't. */  onCode?: (email: string, code: string) => Promise<unknown>;  /** Sends the code or link again. */  onResend?: (email: string) => Promise<unknown>;  /** Shows "Forgot password?" on the password step; sends a reset link. */  onForgot?: (email: string) => Promise<unknown>;  /** After "Signed in" has shown, e.g. to redirect. */  onSuccess?: () => void;  defaultEmail?: string;  codeLength?: number;  /** Seconds before a code or link can be sent again. */  resendAfter?: number;  /** Under the card's content: terms, a link to sign up. */  footer?: React.ReactNode;}const EASE = "cubic-bezier(0.16,1,0.3,1)";const FOCUS = "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40";const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;const SHAKE = [{ transform: "none" }, { transform: "translateX(-4px)" }, { transform: "translateX(4px)" }, { transform: "translateX(-2px)" }, { transform: "none" }];const ORDER: AuthStep[] = ["email", "password", "code", "link", "reset", "done"];const INBOXES: [RegExp, string, string][] = [  [/^(gmail|googlemail)\.com$/, "Gmail", "https://mail.google.com/mail/u/0/#inbox"],  [/^(outlook|hotmail|live|msn)\.[a-z.]+$/, "Outlook", "https://outlook.live.com/mail/"],  [/^(icloud|me|mac)\.com$/, "iCloud Mail", "https://www.icloud.com/mail"],  [/^yahoo\.[a-z.]+$/, "Yahoo Mail", "https://mail.yahoo.com"],  [/^(proton\.me|protonmail\.com|pm\.me)$/, "Proton Mail", "https://mail.proton.me"],];const reducedQuery = "(prefers-reduced-motion: reduce)";const subscribeReduced = (onChange: () => void) => {  const query = window.matchMedia(reducedQuery);  query.addEventListener("change", onChange);  return () => query.removeEventListener("change", onChange);};const useReducedMotion = () =>  React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);const message = (e: unknown, fallback: string) => (e instanceof Error && e.message ? e.message : fallback);const inboxFor = (email: string) => {  const domain = email.split("@")[1]?.toLowerCase() ?? "";  const hit = INBOXES.find(([re]) => re.test(domain));  return hit ? { name: hit[1], href: hit[2] } : null;};/** A line that changes by lifting the old words out through a blur and the new ones in. */function Line({ text, className = "", reduced, as: Tag = "p", id }: { text: string; className?: string; reduced: boolean; as?: "p" | "h2"; id?: string }) {  const [shown, setShown] = React.useState(text);  const [old, setOld] = React.useState<{ key: number; text: string }[]>([]);  const key = React.useRef(0);  const ref = React.useRef<HTMLSpanElement>(null);  if (text !== shown) {    if (!reduced && shown) setOld((o) => [...o, { key: ++key.current, text: shown }]);    setShown(text);  }  React.useLayoutEffect(() => {    if (reduced || !old.length) return;    ref.current?.animate([{ opacity: 0, transform: "translateY(0.3em)", filter: "blur(4px)" }, { opacity: 1, transform: "none", filter: "blur(0px)" }], { duration: 380, delay: 80, easing: EASE, fill: "backwards" });    // eslint-disable-next-line react-hooks/exhaustive-deps  }, [shown]);  return (    <Tag id={id} className={`relative ${className}`}>      <span ref={ref} className="block">        {shown}      </span>      {old.map((o) => (        <Leaving key={o.key} onDone={() => setOld((all) => all.filter((x) => x.key !== o.key))}>          {o.text}        </Leaving>      ))}    </Tag>  );}function Leaving({ children, onDone }: { children: React.ReactNode; onDone: () => void }) {  const ref = React.useRef<HTMLSpanElement>(null);  React.useLayoutEffect(() => {    const a = ref.current?.animate([{ opacity: 1, filter: "blur(0px)" }, { opacity: 0, transform: "translateY(-0.3em)", filter: "blur(4px)" }], { duration: 220, easing: EASE, fill: "forwards" });    if (a) a.onfinish = onDone;    return () => a?.cancel();    // eslint-disable-next-line react-hooks/exhaustive-deps  }, []);  return (    <span ref={ref} aria-hidden="true" className="pointer-events-none absolute inset-x-0 top-0 block">      {children}    </span>  );}function Fold({ open, reduced, children }: { open: boolean; reduced: boolean; children: React.ReactNode }) {  const kept = React.useRef(children);  if (open) kept.current = children;  return (    <div className="grid" inert={!open} aria-hidden={!open || undefined} style={{ gridTemplateRows: open ? "1fr" : "0fr", opacity: open ? 1 : 0, transition: reduced ? "none" : `grid-template-rows 460ms ${EASE}, opacity ${open ? 320 : 180}ms ${EASE}` }}>      <div className="min-h-0 overflow-clip [overflow-clip-margin:6px]">{open ? children : kept.current}</div>    </div>  );}/** The steps under the identity: the new one slides in from the side you went, the old one out the other way, and the box eases to fit. */function Slides({ step, dir, reduced, render }: { step: AuthStep; dir: number; reduced: boolean; render: (s: AuthStep) => React.ReactNode }) {  const [faces, setFaces] = React.useState<{ s: AuthStep; out: boolean; dir: number; enter: boolean }[]>([{ s: step, out: false, dir, enter: false }]);  const [seen, setSeen] = React.useState(step);  const boxRef = React.useRef<HTMLDivElement>(null);  const height = React.useRef(0);  // Clipped only while a step slides; at rest a menu can open past its edge.  const [moving, setMoving] = React.useState(false);  React.useEffect(() => {    if (!moving) return;    const timer = window.setTimeout(() => setMoving(false), 560);    return () => window.clearTimeout(timer);  }, [moving, step]);  if (seen !== step) {    setSeen(step);    if (!reduced) setMoving(true);    setFaces((f) => [...f.filter((x) => x.out && x.s !== step && x.s !== seen), ...(reduced ? [] : [{ s: seen, out: true, dir, enter: false }]), { s: step, out: false, dir, enter: true }]);  }  React.useLayoutEffect(() => {    const el = boxRef.current;    if (!el) return;    height.current = el.offsetHeight;    const observer = new ResizeObserver(() => (height.current = el.offsetHeight));    observer.observe(el);    return () => observer.disconnect();  }, []);  React.useLayoutEffect(() => {    const el = boxRef.current;    if (!el || reduced) return;    const to = el.offsetHeight;    if (Math.abs(to - height.current) > 0.5) el.animate([{ height: `${height.current}px` }, { height: `${to}px` }], { duration: 460, easing: EASE });    // eslint-disable-next-line react-hooks/exhaustive-deps  }, [step]);  return (    <div ref={boxRef} className="relative [overflow-clip-margin:8px]" style={{ overflowY: moving ? "clip" : "visible" }}>      {faces.map((f) => (        <Face key={f.s} out={f.out} enter={f.enter} dir={f.dir} reduced={reduced} onGone={() => setFaces((all) => all.filter((x) => !(x.s === f.s && x.out)))}>          {render(f.s)}        </Face>      ))}    </div>  );}function Face({ out, enter, dir, reduced, onGone, children }: { out: boolean; enter: boolean; dir: number; reduced: boolean; onGone: () => void; children: React.ReactNode }) {  const ref = React.useRef<HTMLDivElement>(null);  React.useLayoutEffect(() => {    const el = ref.current;    if (!el) return;    // A face that comes back while it was still leaving drops its exit first.    el.getAnimations().forEach((a) => a.cancel());    if (reduced) return;    if (out) {      const a = el.animate([{ opacity: 1, transform: "none", filter: "blur(0px)" }, { opacity: 0, transform: `translateX(${-dir * 28}px)`, filter: "blur(4px)" }], { duration: 240, easing: EASE, fill: "forwards" });      a.onfinish = onGone;      return;    }    if (!enter) return;    el.animate([{ opacity: 0, transform: `translateX(${dir * 28}px)`, filter: "blur(4px)" }, { opacity: 1, transform: "none", filter: "blur(0px)" }], { duration: 440, delay: 80, easing: EASE, fill: "backwards" });    // eslint-disable-next-line react-hooks/exhaustive-deps  }, [out]);  return (    <div ref={ref} inert={out} aria-hidden={out || undefined} className={out ? "pointer-events-none absolute inset-x-0 top-0" : ""}>      {children}    </div>  );}/** "Resend in 0:28", rolling down; then Resend → Sending → Sent, and the clock starts again. */function Resend({ after, reduced, onResend }: { after: number; reduced: boolean; onResend: () => Promise<unknown> }) {  const [since, setSince] = React.useState(() => Date.now());  const [now, setNow] = React.useState(since);  const [status, setStatus] = React.useState<ActionStatus>("idle");  const left = Math.max(0, since + after * 1000 - now);  const counting = left > 0;  React.useEffect(() => {    if (!counting) return;    const timer = window.setTimeout(() => setNow(Date.now()), 1000 - (Date.now() % 1000) + 10);    return () => window.clearTimeout(timer);  }, [counting, now]);  React.useEffect(() => {    if (status !== "success" && status !== "error") return;    const timer = window.setTimeout(() => setStatus("idle"), status === "success" ? 1400 : 2400);    return () => window.clearTimeout(timer);  }, [status]);  const ready = !counting && status !== "pending" && status !== "success";  const label = status === "pending" ? "Sending" : status === "success" ? "Sent" : status === "error" ? "Try again" : counting ? "Resend in" : "Resend";  const s = Math.ceil(left / 1000);  return (    <button      type="button"      aria-disabled={!ready || undefined}      aria-label={counting ? "Resend, available shortly" : "Resend"}      onClick={async () => {        if (!ready) return;        setStatus("pending");        try {          await onResend();          setStatus("success");          setSince(Date.now());          setNow(Date.now());        } catch {          setStatus("error");        }      }}      className={`inline-flex h-7 cursor-pointer items-center rounded-full px-2.5 text-[12px] font-medium whitespace-nowrap tabular-nums transition-colors aria-disabled:cursor-default ${FOCUS} ${        status === "error" ? "text-red-600 dark:text-red-400" : ready ? "text-foreground hover:bg-accent" : "text-muted-foreground"      }`}    >      <TextMorph>{label}</TextMorph>      <span        aria-hidden="true"        className="inline-grid"        style={{ gridTemplateColumns: counting && status === "idle" ? "1fr" : "0fr", opacity: counting && status === "idle" ? 1 : 0, transition: reduced ? "none" : `grid-template-columns 380ms ${EASE}, opacity 260ms ${EASE}` }}      >        <span className="min-w-0 [clip-path:inset(-4px_0)]">          <span className="inline-flex items-baseline pl-[0.25em]">            <NumberRoll value={Math.floor(s / 60)} direction="down" />:            <NumberRoll value={s % 60} format={{ minimumIntegerDigits: 2 }} direction="down" />          </span>        </span>      </span>    </button>  );}/** A ring that draws itself, then a check inside it. */function Done({ reduced }: { reduced: boolean }) {  const ring = React.useRef<SVGCircleElement>(null);  const check = React.useRef<SVGPathElement>(null);  React.useLayoutEffect(() => {    if (reduced) return;    ring.current?.animate([{ strokeDashoffset: 1 }, { strokeDashoffset: 0 }], { duration: 640, delay: 120, easing: EASE, fill: "backwards" });    check.current?.animate([{ strokeDashoffset: 1 }, { strokeDashoffset: 0 }], { duration: 420, delay: 560, easing: EASE, fill: "backwards" });  }, [reduced]);  return (    <svg viewBox="0 0 48 48" fill="none" aria-hidden="true" className="size-12 text-emerald-600 dark:text-emerald-400">      <circle cx="24" cy="24" r="22.5" stroke="var(--border)" strokeWidth="1" />      <circle        ref={ring}        cx="24"        cy="24"        r="22.5"        stroke="currentColor"        strokeWidth="1.25"        pathLength={1}        strokeDasharray={1}        transform="rotate(-90 24 24)"        strokeDashoffset={0}      />      <path        ref={check}        d="M16.5 24.5 21.5 29.5 31.5 18.5"        stroke="currentColor"        strokeWidth="2"        strokeLinecap="round"        strokeLinejoin="round"        pathLength={1}        strokeDasharray={1}        strokeDashoffset={0}      />    </svg>  );}export function AuthCard({  mode = "sign-in",  logo,  copy,  providers = [],  onProvider,  onPasskey,  onEmail,  onPassword,  onCode,  onResend,  onForgot,  onSuccess,  defaultEmail = "",  codeLength = 6,  resendAfter = 30,  footer,  className = "",  ...props}: AuthCardProps) {  const id = React.useId();  const reduced = useReducedMotion();  const [step, setStep] = React.useState<AuthStep>("email");  const [dir, setDir] = React.useState(1);  const [email, setEmail] = React.useState(defaultEmail);  const [password, setPassword] = React.useState("");  const [showPassword, setShowPassword] = React.useState(false);  const [busy, setBusy] = React.useState<string | null>(null);  const [status, setStatus] = React.useState<ActionStatus>("idle");  const [error, setError] = React.useState<string | null>(null);  const [announce, setAnnounce] = React.useState("");  const [chipWidth, setChipWidth] = React.useState(0);  const shellRef = React.useRef<HTMLDivElement>(null);  const emailRef = React.useRef<HTMLInputElement>(null);  const mirrorRef = React.useRef<HTMLSpanElement>(null);  const changeRef = React.useRef<HTMLButtonElement>(null);  const passwordRef = React.useRef<HTMLInputElement>(null);  const bodyRef = React.useRef<HTMLDivElement>(null);  const identity = step !== "email";  const signUp = mode === "sign-up";  const words: Record<AuthStep, { title: string; description: string }> = {    email: { title: signUp ? "Create your account" : "Sign in", description: signUp ? "Start with your work email." : "Welcome back. Use the email you signed up with." },    password: { title: "Enter your password", description: "" },    code: { title: "Check your email", description: "We sent you a code. It works for 10 minutes." },    link: { title: "Check your inbox", description: "We sent you a sign-in link. It works for 10 minutes." },    reset: { title: "Check your inbox", description: "We sent a link to reset your password." },    done: { title: signUp ? "You're in" : "Signed in", description: "Taking you to your workspace." },  };  const title = copy?.[step]?.title ?? words[step].title;  const description = copy?.[step]?.description ?? words[step].description;  const go = (next: AuthStep) => {    setDir(ORDER.indexOf(next) >= ORDER.indexOf(step) ? 1 : -1);    setError(null);    setStatus("idle");    setStep(next);    setAnnounce(copy?.[next]?.title ?? words[next].title);  };  const shake = (el: Element | null | undefined) => {    if (!reduced) el?.animate(SHAKE, { duration: 380, easing: "ease-out" });  };  // The chip is as wide as the address plus "Change".  React.useLayoutEffect(() => {    const m = mirrorRef.current;    const c = changeRef.current;    if (!m || !c) return;    const w = Math.ceil(m.offsetWidth + c.offsetWidth + 6);    setChipWidth((p) => (p === w ? p : w));  }, [email, identity]);  // Each step puts focus on the thing to do next.  React.useEffect(() => {    const body = bodyRef.current;    if (step === "email") {      if (dir < 0) emailRef.current?.select();      return;    }    const timer = window.setTimeout(() => {      if (step === "password") passwordRef.current?.focus({ preventScroll: true });      else body?.querySelector<HTMLElement>("[data-autofocus], input, a[href], button:not([aria-disabled])")?.focus({ preventScroll: true });    }, reduced ? 0 : 120);    return () => window.clearTimeout(timer);    // eslint-disable-next-line react-hooks/exhaustive-deps  }, [step]);  React.useEffect(() => {    if (step !== "done" || !onSuccess) return;    const timer = window.setTimeout(onSuccess, reduced ? 400 : 1300);    return () => window.clearTimeout(timer);  }, [step, onSuccess, reduced]);  const submitEmail = async () => {    if (status === "pending") return;    const value = email.trim();    if (!EMAIL.test(value)) {      setError(value ? `“${value}” doesn't look like an email address.` : "Enter your email address.");      shake(shellRef.current);      emailRef.current?.focus();      return;    }    setEmail(value);    setStatus("pending");    setError(null);    try {      const next = await onEmail(value);      go(next);    } catch (e) {      setStatus("error");      setError(message(e, "We couldn't check that address. Try again."));      shake(shellRef.current);    }  };  const submitPassword = async () => {    if (status === "pending" || !onPassword) return;    if (!password) {      setError("Enter your password.");      shake(passwordRef.current?.parentElement);      return;    }    setStatus("pending");    setError(null);    try {      await onPassword(email, password);      go("done");    } catch (e) {      setStatus("error");      setError(message(e, "That password isn't right."));      shake(passwordRef.current?.parentElement);      passwordRef.current?.select();    }  };  const runProvider = async (key: string, run: () => unknown) => {    if (busy) return;    setBusy(key);    setError(null);    try {      await run();      // A redirect never gets here; a popup or a passkey does.      go("done");    } catch (e) {      setBusy(null);      setError(message(e, key === "passkey" ? "Passkey sign-in didn't finish." : "That sign-in didn't finish. Try again."));    }  };  const errorLine = (    <Fold open={Boolean(error)} reduced={reduced}>      <p id={`${id}-error`} className="pt-2 text-[12px] text-red-600 dark:text-red-400">        {error}      </p>    </Fold>  );  const inbox = inboxFor(email);  const render = (s: AuthStep): React.ReactNode => {    if (s === "email")      return (        <div className="pt-3">          <StatusButton            type="submit"            status={status}            labels={{ idle: "Continue", pending: "Checking", success: "Continue", error: "Try again" }}            className="w-full"          />          {onPasskey && (            <button              type="button"              onClick={() => runProvider("passkey", onPasskey)}              aria-busy={busy === "passkey" || undefined}              className={`mt-2 inline-flex h-9 w-full cursor-pointer items-center justify-center gap-2 rounded-full text-[13px] font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground aria-busy:cursor-default ${FOCUS}`}            >              {busy === "passkey" ? <span className="size-3.5 animate-spin rounded-full border-[1.5px] border-current border-t-transparent motion-reduce:animate-none" /> : <KeyRound className="size-4" strokeWidth={1.75} aria-hidden="true" />}              <TextMorph>{busy === "passkey" ? "Waiting for your passkey" : "Sign in with a passkey"}</TextMorph>            </button>          )}        </div>      );    if (s === "password")      return (        <div className="pt-3">          <label htmlFor={`${id}-password`} className="text-[12px] font-medium text-muted-foreground">            Password          </label>          <div className={`mt-1.5 flex h-10 items-center rounded-[12px] pr-1 transition-shadow ${error ? "shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--color-red-500)_45%,transparent)]" : "shadow-[inset_0_0_0_1px_var(--border)] focus-within:shadow-[inset_0_0_0_1px_var(--ring)]"}`}>            <input              ref={passwordRef}              id={`${id}-password`}              type={showPassword ? "text" : "password"}              autoComplete={signUp ? "new-password" : "current-password"}              value={password}              onChange={(e) => {                setPassword(e.target.value);                setError(null);                if (status === "error") setStatus("idle");              }}              aria-invalid={Boolean(error) || undefined}              aria-describedby={error ? `${id}-error` : undefined}              className="h-full min-w-0 flex-1 bg-transparent px-3 text-[16px] text-foreground outline-none [@media(pointer:fine)]:text-[14px]"            />            <button              type="button"              aria-label={showPassword ? "Hide password" : "Show password"}              aria-pressed={showPassword}              onClick={() => setShowPassword((v) => !v)}              className={`relative grid size-8 shrink-0 cursor-pointer place-items-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground ${FOCUS}`}            >              {[false, true].map((on) => (                <span                  key={String(on)}                  aria-hidden="true"                  className="absolute inset-0 grid place-items-center"                  style={{ opacity: showPassword === on ? 1 : 0, transform: showPassword === on ? "none" : "scale(0.6)", filter: showPassword === on || reduced ? "none" : "blur(3px)", transition: reduced ? "none" : `opacity 240ms ${EASE}, transform 360ms ${EASE}, filter 240ms ${EASE}` }}                >                  {on ? <EyeOff className="size-4" strokeWidth={1.75} /> : <Eye className="size-4" strokeWidth={1.75} />}                </span>              ))}            </button>          </div>          {errorLine}          <div className="mt-3 flex items-center justify-between gap-2">            {onForgot ? (              <ForgotButton                onForgot={async () => {                  await onForgot(email);                  go("reset");                }}              />            ) : (              <span />            )}            <StatusButton type="submit" status={status} labels={{ idle: "Sign in", pending: "Signing in", success: "Signed in", error: "Try again" }} />          </div>        </div>      );    if (s === "code")      return (        <div className="pt-4">          <CodeInput            length={codeLength}            autoFocus            onComplete={async (code) => {              await onCode?.(email, code);              window.setTimeout(() => go("done"), reduced ? 0 : 900);              return true;            }}            onResend={onResend ? () => onResend(email) : undefined}            resendAfter={resendAfter}          />        </div>      );    if (s === "link" || s === "reset")      return (        <div className="flex flex-wrap items-center gap-2 pt-4">          {inbox && (            <a              data-autofocus=""              href={inbox.href}              target="_blank"              rel="noopener noreferrer"              className={`inline-flex h-9 cursor-pointer items-center gap-1.5 rounded-full bg-primary px-4 text-[13px] font-medium text-primary-foreground transition-colors hover:bg-primary/90 ${FOCUS}`}            >              Open {inbox.name}              <ArrowUpRight className="size-3.5" strokeWidth={2} aria-hidden="true" />            </a>          )}          {onResend && <Resend after={resendAfter} reduced={reduced} onResend={() => onResend(email)} />}        </div>      );    return (      <div className="flex flex-col items-center pt-5 pb-1 text-center">        <Done reduced={reduced} />      </div>    );  };  return (    <div className={`w-full rounded-[22px] border border-border bg-background p-5 text-foreground sm:p-6 ${className}`} {...props}>      {logo && <div className="mb-4">{logo}</div>}      <Line as="h2" id={`${id}-title`} text={title} reduced={reduced} className="text-[19px] font-medium tracking-[-0.02em]" />      <Fold open={Boolean(description)} reduced={reduced}>        <Line text={description} reduced={reduced} className="mt-1 text-[13px] leading-[1.5] text-muted-foreground" />      </Fold>      <form        noValidate        aria-labelledby={`${id}-title`}        onSubmit={(e) => {          e.preventDefault();          if (step === "email") submitEmail();          else if (step === "password") submitPassword();        }}      >        {/* Providers fold away above the address once it's chosen. */}        <Fold open={!identity && providers.length > 0} reduced={reduced}>          <div className="pt-5">            <div className={`grid gap-2 ${providers.length > 1 ? "grid-cols-2" : ""}`}>              {providers.map((p) => (                <button                  key={p.id}                  type="button"                  onClick={() => onProvider && runProvider(p.id, () => onProvider(p.id))}                  aria-busy={busy === p.id || undefined}                  disabled={Boolean(busy) && busy !== p.id}                  className={`inline-flex h-10 cursor-pointer items-center justify-center gap-2 rounded-full text-[13px] font-medium shadow-[inset_0_0_0_1px_var(--border)] transition-[background-color,opacity] duration-200 hover:bg-accent disabled:cursor-default disabled:opacity-40 aria-busy:cursor-default ${FOCUS}`}                >                  <span className="relative grid size-4 place-items-center [&_svg]:size-4" aria-hidden="true">                    <span className="absolute inset-0 grid place-items-center" style={{ opacity: busy === p.id ? 0 : 1, transform: busy === p.id ? "scale(0.6)" : "none", transition: reduced ? "none" : `opacity 200ms ${EASE}, transform 320ms ${EASE}` }}>                      {p.icon}                    </span>                    <span                      className={`absolute inset-0.5 rounded-full border-[1.5px] border-current border-t-transparent motion-reduce:animate-none ${busy === p.id ? "animate-spin" : ""}`}                      style={{ opacity: busy === p.id ? 1 : 0, transition: reduced ? "none" : `opacity 200ms ${EASE}` }}                    />                  </span>                  {p.label}                </button>              ))}            </div>            <div className="mt-5 flex items-center gap-3 text-[11.5px] text-muted-foreground" aria-hidden="true">              <span className="h-px flex-1 bg-border" />              or              <span className="h-px flex-1 bg-border" />            </div>          </div>        </Fold>        <div className="pt-4">          {!identity && (            <label htmlFor={`${id}-email`} className="mb-1.5 block text-[12px] font-medium text-muted-foreground">              Email            </label>          )}          {/* The address: a field, then the same frame shrunk round it as a chip. */}          <div            ref={shellRef}            className="relative flex items-center overflow-hidden"            style={{              width: identity ? chipWidth || "auto" : "100%",              maxWidth: "100%",              height: identity ? 32 : 40,              borderRadius: identity ? 16 : 12,              boxShadow: identity ? "inset 0 0 0 1px var(--border)" : error && step === "email" ? "inset 0 0 0 1px color-mix(in oklab, var(--color-red-500) 45%, transparent)" : undefined,              transition: reduced ? "none" : `width 520ms ${EASE}, height 520ms ${EASE}, border-radius 520ms ${EASE}, box-shadow 200ms`,            }}          >            <span              aria-hidden="true"              className={`pointer-events-none absolute inset-0 rounded-[inherit] transition-shadow duration-200 ${identity || (error && step === "email") ? "" : "shadow-[inset_0_0_0_1px_var(--border)] [:focus-within>&]:shadow-[inset_0_0_0_1px_var(--ring)]"}`}            />            <span ref={mirrorRef} aria-hidden="true" className="pointer-events-none invisible absolute left-0 top-0 whitespace-pre px-3 text-[16px] [@media(pointer:fine)]:text-[14px]">              {email || " "}            </span>            <input              ref={emailRef}              id={`${id}-email`}              type="email"              inputMode="email"              autoComplete={onPasskey ? "username webauthn" : "email"}              placeholder="you@company.com"              value={email}              readOnly={identity || status === "pending"}              tabIndex={identity ? -1 : undefined}              aria-label={identity ? `Signing in as ${email}` : undefined}              aria-invalid={(Boolean(error) && step === "email") || undefined}              aria-describedby={error && step === "email" ? `${id}-error` : undefined}              onChange={(e) => {                setEmail(e.target.value);                setError(null);                if (status === "error") setStatus("idle");              }}              className="h-full min-w-0 flex-1 bg-transparent px-3 text-[16px] text-foreground outline-none placeholder:text-muted-foreground/70 [@media(pointer:fine)]:text-[14px]"            />            <span              className="inline-grid shrink-0"              style={{                gridTemplateColumns: identity && step !== "done" ? "1fr" : "0fr",                opacity: identity && step !== "done" ? 1 : 0,                transition: reduced ? "none" : `grid-template-columns 420ms ${EASE} ${identity ? 160 : 0}ms, opacity 260ms ${EASE} ${identity ? 220 : 0}ms`,              }}              inert={!identity || step === "done"}            >              <span className="min-w-0 overflow-clip">                <button                  ref={changeRef}                  type="button"                  onClick={() => {                    setPassword("");                    go("email");                  }}                  className={`mr-1 h-6 cursor-pointer rounded-full px-2 text-[12px] font-medium whitespace-nowrap text-muted-foreground transition-colors hover:bg-accent hover:text-foreground ${FOCUS}`}                >                  Change                </button>              </span>            </span>          </div>          {step === "email" && errorLine}        </div>        <div ref={bodyRef}>          <Slides step={step} dir={dir} reduced={reduced} render={render} />        </div>      </form>      {footer && (        <Fold open={step === "email"} reduced={reduced}>          <div className="pt-5 text-center text-[12px] text-muted-foreground [&_a]:font-medium [&_a]:text-foreground [&_a]:underline-offset-2 hover:[&_a]:underline">{footer}</div>        </Fold>      )}      <span className="sr-only" role="status">        {announce}      </span>    </div>  );}function ForgotButton({ onForgot }: { onForgot: () => Promise<void> }) {  const [status, setStatus] = React.useState<ActionStatus>("idle");  return (    <button      type="button"      aria-disabled={status === "pending" || undefined}      onClick={async () => {        if (status === "pending") return;        setStatus("pending");        try {          await onForgot();        } catch {          setStatus("error");        }      }}      className={`-ml-2 h-7 cursor-pointer rounded-full px-2 text-[12px] font-medium transition-colors hover:bg-accent ${FOCUS} ${status === "error" ? "text-red-600 dark:text-red-400" : "text-muted-foreground hover:text-foreground"}`}    >      <TextMorph>{status === "pending" ? "Sending a link" : status === "error" ? "Couldn't send it. Try again" : "Forgot password?"}</TextMorph>    </button>  );}

Registry item: /r/auth-card.json