Auth Dialog
An opinionated sign-in dialog with OAuth provider buttons, a passkey option, and a unified email continue flow.
Installation
Usage
import { AuthDialog } from "@/components/ui/auth-dialog"Examples
Default
All four providers, passkey, and magic-link email in the unified continue flow. Handlers return promises, so the dialog runs the pending choreography and closes on resolve via controlled open. The footer slot carries the terms line.
Password Mode
emailMode="password" adds an inline password field and guarantees password in the continue payload. Also shows trimming the surface: one provider, no passkey.
Pending State
While a handler's promise is in flight, the clicked method shows a spinner (after a 200ms grace, so fast responses never flash) and every other method disables. Escape and the close button keep working.
Error Handling
A rejected promise un-pends the dialog and announces the rejection's message in the reserved error line under the header. The next attempt clears it.
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
| providersAuthDialog | ("google" | "github" | "microsoft" | "apple")[] | all four | Which OAuth provider buttons to render, in array order. Pass [] to render none. |
| showPasskeyAuthDialog | boolean | true | Renders the passkey button in the method stack. |
| showEmailAuthDialog | boolean | true | Renders the email form (and the separator when methods render above it). |
| emailModeAuthDialog | "magic-link" | "password" | "magic-link" | Magic link collects email only. Password adds an inline password field and types password as required in the continue payload. |
| submitLabelAuthDialog | React.ReactNode | "Send magic link" / "Continue" | Overrides the email submit button's label. The default follows emailMode, because the two modes promise different things: in magic-link the button does not sign you in, it sends a mail and the next screen is your inbox, so it reads "Send magic link"; in password the submit really does sign you in, so it reads "Continue". Pass anything to override either, e.g. submitLabel="Continue" to keep the generic wording. |
| onProviderContinueAuthDialog | (provider) => void | Promise<void> | - | Called with the clicked provider. Return a promise to get pending and error handling; return void for an immediate redirect. |
| onPasskeyContinueAuthDialog | () => void | Promise<void> | - | Called when the passkey button is clicked. Same promise contract. |
| onEmailContinueAuthDialog | ({ email, password? }) => void | Promise<void> | - | Called on a valid email submit (native validation gates it). The payload is discriminated by emailMode. |
| titleAuthDialog | ReactNode | "Sign in" | Dialog title. |
| descriptionAuthDialog | ReactNode | "Choose how you want to continue" | Dialog description under the title. |
| triggerAuthDialog | ReactNode | - | Rendered through DialogTrigger. Omit when driving open externally. |
| footerAuthDialog | ReactNode | - | Terms/privacy copy pinned at the bottom in small muted text. |
| openAuthDialog | boolean | - | Controlled open state, forwarded to the underlying Dialog. Pair with onOpenChange. |
| onOpenChangeAuthDialog | (open: boolean) => void | - | Called when the open state changes. |
| defaultOpenAuthDialog | boolean | false | Uncontrolled initial open state. |
"use client"
import * as React from "react"
import { Fingerprint } from "lucide-react"
import { cn } from "@/registry/lib/utils"
import { Button } from "@/registry/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/registry/ui/dialog"
import { Input } from "@/registry/ui/input"
import { Label } from "@/registry/ui/label"
import { Separator } from "@/registry/ui/separator"
const AUTH_PROVIDERS = ["google", "github", "microsoft", "apple"] as const
type AuthProvider = (typeof AUTH_PROVIDERS)[number]
type AuthMethod = AuthProvider | "passkey" | "email"
/**
* Brand marks are inlined (Simple Icons path data, CC0) rather than pulled
* in as a dependency: lucide dropped brand icons and four glyphs do not
* justify an icon package. Google and Microsoft are fixed-fill multicolor
* marks; GitHub and Apple follow the button's text color.
*/
function GoogleMark(props: React.ComponentProps<"svg">) {
return (
<svg viewBox="0 0 48 48" aria-hidden="true" {...props}>
<path
fill="#4285F4"
d="M45.12 24.5c0-1.56-.14-3.06-.4-4.5H24v8.51h11.84c-.51 2.75-2.06 5.08-4.39 6.64v5.52h7.11c4.16-3.83 6.56-9.47 6.56-16.17z"
/>
<path
fill="#34A853"
d="M24 46c5.94 0 10.92-1.97 14.56-5.33l-7.11-5.52c-1.97 1.32-4.49 2.1-7.45 2.1-5.73 0-10.58-3.87-12.31-9.07H4.34v5.7C7.96 41.07 15.4 46 24 46z"
/>
<path
fill="#FBBC05"
d="M11.69 28.18C11.25 26.86 11 25.45 11 24s.25-2.86.69-4.18v-5.7H4.34C2.85 17.09 2 20.45 2 24s.85 6.91 2.34 9.88l7.35-5.7z"
/>
<path
fill="#EA4335"
d="M24 10.75c3.23 0 6.13 1.11 8.41 3.29l6.31-6.31C34.91 4.18 29.93 2 24 2 15.4 2 7.96 6.93 4.34 14.12l7.35 5.7c1.73-5.2 6.58-9.07 12.31-9.07z"
/>
</svg>
)
}
function GitHubMark(props: React.ComponentProps<"svg">) {
return (
<svg viewBox="0 0 24 24" aria-hidden="true" {...props}>
<path
fill="currentColor"
d="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"
/>
</svg>
)
}
function MicrosoftMark(props: React.ComponentProps<"svg">) {
return (
<svg viewBox="0 0 24 24" aria-hidden="true" {...props}>
<path fill="#F25022" d="M1 1h10v10H1z" />
<path fill="#7FBA00" d="M13 1h10v10H13z" />
<path fill="#00A4EF" d="M1 13h10v10H1z" />
<path fill="#FFB900" d="M13 13h10v10H13z" />
</svg>
)
}
function AppleMark(props: React.ComponentProps<"svg">) {
return (
<svg viewBox="0 0 24 24" aria-hidden="true" {...props}>
<path
fill="currentColor"
d="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.56-1.702"
/>
</svg>
)
}
const providerConfig: Record<
AuthProvider,
{ label: string; Mark: (props: React.ComponentProps<"svg">) => React.ReactElement }
> = {
google: { label: "Google", Mark: GoogleMark },
github: { label: "GitHub", Mark: GitHubMark },
microsoft: { label: "Microsoft", Mark: MicrosoftMark },
apple: { label: "Apple", Mark: AppleMark },
}
/**
* Delays a flag by `delayMs` so fast callbacks never flash a spinner.
* The logical pending state (sibling disabling, click guarding) stays
* instant; only the visual loading treatment waits.
*/
function useDelayedFlag(active: boolean, delayMs: number) {
const [delayed, setDelayed] = React.useState(false)
React.useEffect(() => {
if (!active) return
const id = window.setTimeout(() => setDelayed(true), delayMs)
return () => {
window.clearTimeout(id)
setDelayed(false)
}
}, [active, delayMs])
return delayed && active
}
function errorMessage(err: unknown) {
return err instanceof Error && err.message
? err.message
: "Something went wrong. Try again."
}
/**
* Email flow payload is discriminated on `emailMode`: password mode
* guarantees `password` in the payload at the type level.
*/
type AuthDialogEmailProps =
| {
emailMode?: "magic-link"
onEmailContinue?: (payload: { email: string }) => void | Promise<void>
}
| {
emailMode: "password"
onEmailContinue?: (payload: {
email: string
password: string
}) => void | Promise<void>
}
type AuthDialogProps = Pick<
React.ComponentProps<typeof Dialog>,
"open" | "defaultOpen" | "onOpenChange"
> &
Omit<React.ComponentProps<typeof DialogContent>, "children" | "title"> &
AuthDialogEmailProps & {
/** Which OAuth providers to render, in array order. */
providers?: AuthProvider[]
showPasskey?: boolean
showEmail?: boolean
/**
* Every handler is optional and consumer-provided. Return a promise
* and the dialog manages pending and error states; return void (for
* an immediate redirect) and it stays out of the way.
*/
onProviderContinue?: (provider: AuthProvider) => void | Promise<void>
onPasskeyContinue?: () => void | Promise<void>
title?: React.ReactNode
description?: React.ReactNode
/**
* Label for the email submit button.
*
* Defaults to the honest verb for the mode. In `magic-link` the button does
* NOT sign you in, it sends a mail and the next thing you see is your
* inbox, so "Continue" promises the wrong outcome and the default is
* "Send magic link". In `password` the submit really does sign you in, so
* it stays "Continue". Override for product-specific wording.
*/
submitLabel?: React.ReactNode
/** Rendered through DialogTrigger. Omit when driving `open` externally. */
trigger?: React.ReactNode
/** Terms/privacy copy pinned at the bottom in small muted text. */
footer?: React.ReactNode
}
function AuthDialog({
providers = ["google", "github", "microsoft", "apple"],
showPasskey = true,
showEmail = true,
emailMode = "magic-link",
onProviderContinue,
onPasskeyContinue,
onEmailContinue,
title = "Sign in",
description = "Choose how you want to continue",
submitLabel,
trigger,
footer,
open,
defaultOpen,
onOpenChange,
className,
...props
}: AuthDialogProps) {
const [pendingMethod, setPendingMethod] = React.useState<AuthMethod | null>(null)
const [error, setError] = React.useState<string | null>(null)
// ~200ms grace before the spinner shows; see useDelayedFlag.
const spinnerVisible = useDelayedFlag(pendingMethod !== null, 200)
const emailId = React.useId()
const passwordId = React.useId()
// The union collapses to one internal shape; the discriminated types
// exist for the consumer's benefit.
const handleEmailContinue = onEmailContinue as
| ((payload: { email: string; password?: string }) => void | Promise<void>)
| undefined
React.useEffect(() => {
if (process.env.NODE_ENV === "production") return
if (providers.length > 0 && !onProviderContinue)
console.error("[auth-dialog] provider buttons are rendered but onProviderContinue is not set.")
if (showPasskey && !onPasskeyContinue)
console.error("[auth-dialog] the passkey button is rendered but onPasskeyContinue is not set.")
if (showEmail && !onEmailContinue)
console.error("[auth-dialog] the email form is rendered but onEmailContinue is not set.")
}, [providers.length, onProviderContinue, showPasskey, onPasskeyContinue, showEmail, onEmailContinue])
// Single-flight: guards double clicks during the pre-spinner grace window
// too, since Button only blocks clicks once `loading` is visually true.
function run(method: AuthMethod, callback: () => void | Promise<void> | undefined) {
if (pendingMethod) return
setError(null)
let result: void | Promise<void> | undefined
try {
result = callback()
} catch (err) {
setError(errorMessage(err))
return
}
if (result && typeof result.then === "function") {
setPendingMethod(method)
result.then(
() => setPendingMethod(null),
(err: unknown) => {
setPendingMethod(null)
setError(errorMessage(err))
}
)
}
}
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
const data = new FormData(event.currentTarget)
const email = String(data.get("email") ?? "")
const password = String(data.get("password") ?? "")
run("email", () =>
handleEmailContinue?.(
emailMode === "password" ? { email, password } : { email }
)
)
}
const hasMethodStack = providers.length > 0 || showPasskey
const otherMethodPending = (method: AuthMethod) =>
pendingMethod !== null && pendingMethod !== method
return (
<Dialog open={open} defaultOpen={defaultOpen} onOpenChange={onOpenChange}>
{trigger != null && <DialogTrigger render={trigger as React.ReactElement} />}
<DialogContent
data-slot="auth-dialog"
className={cn("sm:max-w-sm", className)}
{...props}
>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
{/* Always mounted with one reserved line: a live region injected
together with its text is not announced, and a line appearing
later would shift the centered panel. */}
<p
data-slot="auth-dialog-error"
aria-live="polite"
className="min-h-5 text-sm leading-5 text-destructive"
>
{error}
</p>
</DialogHeader>
{hasMethodStack && (
<div data-slot="auth-dialog-providers" className="flex flex-col gap-2">
{providers.map((provider) => {
const { label, Mark } = providerConfig[provider]
return (
<Button
key={provider}
variant="subtle"
size="lg"
className="w-full"
loading={spinnerVisible && pendingMethod === provider}
disabled={otherMethodPending(provider)}
onClick={() => run(provider, () => onProviderContinue?.(provider))}
>
<Mark />
Continue with {label}
</Button>
)
})}
{showPasskey && (
<Button
variant="subtle"
size="lg"
className="w-full"
loading={spinnerVisible && pendingMethod === "passkey"}
disabled={otherMethodPending("passkey")}
onClick={() => run("passkey", () => onPasskeyContinue?.())}
>
<Fingerprint />
Continue with passkey
</Button>
)}
</div>
)}
{showEmail && (
<>
{hasMethodStack && (
<div className="flex items-center gap-3">
<Separator className="flex-1" aria-hidden="true" />
<span className="text-xs text-muted-foreground">
or continue with
</span>
<Separator className="flex-1" aria-hidden="true" />
</div>
)}
<form
data-slot="auth-dialog-form"
className="flex flex-col gap-3"
onSubmit={handleSubmit}
>
<div className="flex flex-col gap-2">
<Label htmlFor={emailId}>Email</Label>
<Input
id={emailId}
name="email"
type="email"
size="lg"
required
spellCheck={false}
placeholder="you@example.com"
// The webauthn token opts this field into the browser's
// passkey autofill once the consumer wires conditional
// WebAuthn mediation.
autoComplete="username webauthn"
disabled={otherMethodPending("email")}
/>
</div>
{emailMode === "password" && (
<div className="flex flex-col gap-2">
<Label htmlFor={passwordId}>Password</Label>
<Input
id={passwordId}
name="password"
type="password"
size="lg"
required
autoComplete="current-password"
disabled={otherMethodPending("email")}
/>
</div>
)}
<Button
type="submit"
size="lg"
className="w-full"
loading={spinnerVisible && pendingMethod === "email"}
disabled={otherMethodPending("email")}
>
{submitLabel ??
(emailMode === "password" ? "Continue" : "Send magic link")}
</Button>
</form>
</>
)}
{footer != null && (
<div
data-slot="auth-dialog-footer"
// Anchors get the design system's inline-link rule (accent,
// underlined; see site-footer's inline link treatment) so consumer
// terms/privacy links are correct with zero classes.
className="text-center text-xs text-muted-foreground [&_a]:rounded-sm [&_a]:text-accent [&_a]:underline [&_a]:outline-none [&_a]:hover:no-underline [&_a]:focus-visible:ring-1 [&_a]:focus-visible:ring-ring-accent"
>
{footer}
</div>
)}
</DialogContent>
</Dialog>
)
}
export { AuthDialog, AUTH_PROVIDERS }
export type { AuthDialogProps, AuthProvider }