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