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 under the header. The message region is collapsed until it has something to say, so the panel grows to make room and shrinks again on the next attempt.
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 { IconFingerprint as Fingerprint } from "@tabler/icons-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"
// AUTH_PROVIDERS is a plain value, but a client module's exports are still
// client references under RSC, so it lives in a directive-free sibling
// module along with the type it derives. See auth-providers.ts.
import { AUTH_PROVIDERS, type AuthProvider } from "@/registry/ui/auth-providers"
type AuthMethod = AuthProvider | "passkey" | "email"
/**
* Brand marks are inlined (Simple Icons path data, CC0) rather than pulled from
* the icon set: Google and Microsoft are fixed-fill multicolor marks that a
* monochrome icon library cannot represent, so inlining is the honest option.
* Google and Microsoft keep their brand fills; 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
}
// @use-when a sign-in or sign-up dialog offering provider buttons, a passkey,
// or an email form.
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)
// The last message stays rendered while the error region collapses, so the
// panel animates DOWN from a real height instead of snapping: `1fr` sizes to
// content, and content that empties in the same frame is already 0 tall.
// Only the visible copy retains it; the announced one clears with `error`.
//
// State adjusted during render, React's own documented pattern, and NOT a
// ref. A ref held the retained copy for most of this component's life and
// both halves of that broke the rules a ref is bound by: it was written
// during render and read during render, which `react-hooks/refs` reports as
// two errors. It is not a lint technicality either. A ref write is invisible
// to React, so under StrictMode's double render, and under any future
// interruption of a render that is later thrown away, the retained message
// can be one the user never saw. The guard is what makes this cheap: React
// re-runs the component immediately, before committing or painting, and only
// when the message actually changed, so the rendered output is identical to
// what the ref produced and the collapse still animates from a real height.
const [lastError, setLastError] = React.useState<string | null>(null)
if (error !== null && error !== lastError) setLastError(error)
// ~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>
{/* The description and the error share a GAPLESS wrapper. The header
spaces its children with a flex gap, and a flex gap still paints
above a child of zero height, so collapsing the error alone would
leave the rung behind as 12px of nothing. The collapsing region
owns that gap instead, as padding on the part that collapses. */}
<div className="flex flex-col">
<DialogDescription>{description}</DialogDescription>
{/* Announcement and appearance are split, because one element
cannot do both jobs. The live region is permanently mounted and
costs no space (`sr-only` is out of flow, so it takes no flex
gap either): a region injected together with its text is not
announced, `display: none` drops it from the accessibility tree,
and a region clipped to zero height is skipped by some screen
readers. Its text tracks `error` EXACTLY, so clearing and
re-setting the same message is still two mutations and the
repeat is announced. */}
<span aria-live="polite" className="sr-only">
{error}
</span>
{/* The visible copy is `aria-hidden` (the region above speaks for
it) and collapses to nothing when there is no error, which is
nearly every open. Height is animated because the panel is
centered on `translate-y-[-50%]`, so a height change moves it;
200ms ease-out is the disclosure timing, matching
card-animate-height. */}
<div
aria-hidden="true"
className={cn(
"grid transition-[grid-template-rows] duration-200 ease-out motion-reduce:transition-none",
error ? "grid-rows-[1fr]" : "grid-rows-[0fr]"
)}
>
<div className="overflow-hidden">
<p
data-slot="auth-dialog-error"
// The plain rung, not the compensated one: neither the
// description above nor this line is cap-trimmed, so both
// still supply their own half-leading. The 12px it used to
// sit at was the header's trim-compensated gap spilling down
// from the title, which painted the pair about 8px loose.
// `leading-5` pins the line box to the 20px it is measured at:
// a 21px box moved the centered panel by 1px. See PROJECT_LOG.
className="pt-(--spacing-rhythm-pair) text-sm leading-5 text-destructive"
>
{error ?? lastError}
</p>
</div>
</div>
</div>
</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>
)
}
// rsc-reexport: AUTH_PROVIDERS -> auth-providers
// kept for existing client call sites; server callers import the sibling
export { AuthDialog, AUTH_PROVIDERS }
export type { AuthDialogProps, AuthProvider }const AUTH_PROVIDERS = ["google", "github", "microsoft", "apple"] as const
type AuthProvider = (typeof AUTH_PROVIDERS)[number]
// NO "use client" HERE, ON PURPOSE. `auth-dialog.tsx` is a client module, and
// under React Server Components every export of a client module becomes a
// client reference: renderable or passable as a prop, never usable as a plain
// value. Calling `.map()` on AUTH_PROVIDERS from a Server Component throws.
// This lives in its own directive-free module so a Server Component can read
// it; `auth-dialog.tsx` re-exports it for the client callers that already
// import from there.
//
// It sits DOWN HERE rather than at the top of the file because the shadcn CLI
// drops everything above a file's first statement when it installs it, so a
// warning written at the top reaches nobody it is written for.
export { AUTH_PROVIDERS }
export type { AuthProvider }