Auth Form
The sign-in form on a page of its own: provider buttons, a passkey, an email form, a forgot-password link, and an error you can set.
Installation
$
npx shadcn@latest add https://ui.mattdowney.com/r/auth-form.jsonUsage
import { AuthForm } from "@/components/ui/auth-form"Examples
Default
A sign-in page's form: two providers, email and password, a forgot-password link and a footer link to sign up. Plain anchors in both slots are styled for you.
Wrong password
Many auth clients return a failure instead of throwing it. Pass that message as error; clear it when the next attempt starts. Submit to see it.
Sign in
Choose how you want to continue
Magic link
The default email mode sends a link instead of asking for a password, so the submit reads "Send magic link".
Sign in
Choose how you want to continue
or continue with
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
| providersAuthForm | ("google" | "github" | "microsoft" | "apple")[] | all four | Which OAuth provider buttons to render, in array order. Pass [] to render none. |
| showPasskeyAuthForm | boolean | true | Renders the passkey button in the method stack. |
| showEmailAuthForm | boolean | true | Renders the email form (and the divider when methods render above it). |
| emailModeAuthForm | "magic-link" | "password" | "magic-link" | Magic link collects email only. Password adds a password field and types password as required in the continue payload. |
| forgotPasswordAuthForm | ReactNode | - | Rendered beside the Password label in password mode. Pass the link itself, an <a> or a router Link; it gets Button's link look with no classes. |
| errorAuthForm | string | null | - | An error you already know about, such as a wrong password your auth client returned or a ?error= code from a redirect. Shown and announced under the description. A rejected handler's message wins while it lasts. |
| submitLabelAuthForm | React.ReactNode | "Send magic link" / "Continue" | Overrides the email submit button's label. The default follows emailMode. |
| onProviderContinueAuthForm | (provider) => void | Promise<void> | - | Called with the clicked provider. Return a promise to get pending and error handling; return void for an immediate redirect. |
| onPasskeyContinueAuthForm | () => void | Promise<void> | - | Called when the passkey button is clicked. Same promise contract. |
| onEmailContinueAuthForm | ({ email, password? }) => void | Promise<void> | - | Called on a valid email submit (native validation gates it). The payload is discriminated by emailMode. |
| titleAuthForm | ReactNode | "Sign in" | Rendered as the page's h1. |
| descriptionAuthForm | ReactNode | "Choose how you want to continue" | Line under the title. |
| footerAuthForm | ReactNode | - | Small centered copy at the bottom, such as a sign-up link or terms. Anchors get the inline-link style. |
| surfaceAuthForm | "card" | "bare" | "card" | card draws the form on a Card, for a page with other things on it. bare drops the Card so the form stands on the page, as it does in Auth Layout, and sets the title one step larger. |
| classNameAuthForm | string | - | Applied to the root (the Card, or the bare wrapper). It is w-full max-w-sm; place it with the page's own layout or Auth Layout. |
providersAuthForm("google" | "github" | "microsoft" | "apple")[] = all fourWhich OAuth provider buttons to render, in array order. Pass [] to render none. |
| showPasskeyAuthFormboolean = trueRenders the passkey button in the method stack. |
| showEmailAuthFormboolean = trueRenders the email form (and the divider when methods render above it). |
emailModeAuthForm"magic-link" | "password" = "magic-link"Magic link collects email only. Password adds a password field and types password as required in the continue payload. |
forgotPasswordAuthFormReactNodeRendered beside the Password label in password mode. Pass the link itself, an <a> or a router Link; it gets Button's link look with no classes. |
errorAuthFormstring | nullAn error you already know about, such as a wrong password your auth client returned or a ?error= code from a redirect. Shown and announced under the description. A rejected handler's message wins while it lasts. |
submitLabelAuthFormReact.ReactNode = "Send magic link" / "Continue"Overrides the email submit button's label. The default follows emailMode. |
| onProviderContinueAuthForm(provider) => void | Promise<void>Called with the clicked provider. Return a promise to get pending and error handling; return void for an immediate redirect. |
| onPasskeyContinueAuthForm() => void | Promise<void>Called when the passkey button is clicked. Same promise contract. |
onEmailContinueAuthForm({ email, password? }) => void | Promise<void>Called on a valid email submit (native validation gates it). The payload is discriminated by emailMode. |
titleAuthFormReactNode = "Sign in"Rendered as the page's h1. |
| descriptionAuthFormReactNode = "Choose how you want to continue"Line under the title. |
| footerAuthFormReactNodeSmall centered copy at the bottom, such as a sign-up link or terms. Anchors get the inline-link style. |
surfaceAuthForm"card" | "bare" = "card"card draws the form on a Card, for a page with other things on it. bare drops the Card so the form stands on the page, as it does in Auth Layout, and sets the title one step larger. |
classNameAuthFormstringApplied to the root (the Card, or the bare wrapper). It is w-full max-w-sm; place it with the page's own layout or Auth Layout. |
"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 { Card } from "@/registry/ui/card"
import { Input } from "@/registry/ui/input"
import { Label } from "@/registry/ui/label"
import { Separator } from "@/registry/ui/separator"
import { type AuthProvider } from "@/registry/ui/auth-providers"
import {
AppleMark,
GitHubMark,
GoogleMark,
MicrosoftMark,
} from "@/registry/ui/brand-marks"
type AuthMethod = AuthProvider | "passkey" | "email"
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 AuthFormEmailProps =
| {
emailMode?: "magic-link"
onEmailContinue?: (payload: { email: string }) => void | Promise<void>
}
| {
emailMode: "password"
onEmailContinue?: (payload: {
email: string
password: string
}) => void | Promise<void>
}
/** Everything the sign-in body takes, shared by AuthForm and AuthDialog. */
type AuthFormOptions = AuthFormEmailProps & {
/** 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 form 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
/** Terms/privacy copy pinned at the bottom in small muted text. */
footer?: React.ReactNode
/**
* An error the caller already knows about, shown in the same place as a
* rejected handler's message: a wrong password an auth client returned as
* a value rather than threw, or a `?error=` code a redirect brought back.
* A string, not a node, because the shown copy is compared by value to
* decide when to animate. A rejected handler's message wins while it lasts.
*/
error?: string | null
/**
* Rendered beside the Password label in `password` mode, for a "Forgot
* password?" link. Pass the link itself (`<a>` or a router Link): anchors
* in this slot get Button's `link` look, so it needs no classes.
*/
forgotPassword?: React.ReactNode
}
/**
* The heading parts and slot prefix a host surface brings. AuthDialog passes
* its Dialog parts, so the dialog's own title labels it and its header keeps
* the close-button inset; with no `embed`, AuthForm draws its own Card and an
* `h1`.
*/
type AuthFormEmbed = {
/** Prefix for every inner `data-slot`, e.g. `auth-dialog` -> `auth-dialog-form`. */
slot: string
Header: React.ElementType<{ children?: React.ReactNode }>
Title: React.ElementType<{ children?: React.ReactNode }>
Description: React.ElementType<{ children?: React.ReactNode }>
}
type AuthFormProps = Omit<React.ComponentProps<"div">, "title"> &
AuthFormOptions & {
/**
* Render inside a host surface instead of a Card of its own. The form's
* blocks then become direct children of the host, which spaces them (the
* Dialog popup is a grid on the same `heading-md` rung the Card body uses),
* and `className` and the rest of the div props are ignored: style the
* host instead.
*/
embed?: AuthFormEmbed
/**
* `card` (default) draws the form on a Card, for a page with other
* things on it. `bare` drops the Card so the form stands on the page
* itself, as it does in AuthLayout, where a box around the only thing on
* the screen would be a surface with nothing to separate it from.
*/
surface?: "card" | "bare"
}
function AuthFormHeader({ children }: { children?: React.ReactNode }) {
return (
<div
data-slot="auth-form-header"
className="flex flex-col"
// Same rung DialogHeader uses: the title is cap-trimmed, so the gap
// under it is the trim-compensated pair.
style={{ gap: "var(--spacing-rhythm-trim-pair)" }}
>
{children}
</div>
)
}
// An h1: standing on a page, this IS the page's heading. One step above
// DialogTitle on a Card, which shares the page with whatever is around it, and
// one more when bare, where it heads the whole screen. The rest of the
// treatment (trim, snug leading, pretty wrap) is DialogTitle's, for the
// reasons written there.
function AuthFormCardTitle({ children }: { children?: React.ReactNode }) {
return (
<h1
data-slot="auth-form-title"
className="cap-trim text-xl leading-snug font-semibold text-pretty"
>
{children}
</h1>
)
}
function AuthFormBareTitle({ children }: { children?: React.ReactNode }) {
return (
<h1
data-slot="auth-form-title"
className="cap-trim text-2xl leading-snug font-semibold text-pretty"
>
{children}
</h1>
)
}
function AuthFormDescription({ children }: { children?: React.ReactNode }) {
return (
<p
data-slot="auth-form-description"
className="text-muted-foreground text-sm text-pretty"
>
{children}
</p>
)
}
const cardParts: AuthFormEmbed = {
slot: "auth-form",
Header: AuthFormHeader,
Title: AuthFormCardTitle,
Description: AuthFormDescription,
}
const bareParts: AuthFormEmbed = { ...cardParts, Title: AuthFormBareTitle }
// @use-when the sign-in or sign-up form itself, on a page rather than in a
// dialog: provider buttons, a passkey, email and password, a forgot-password
// link. The screen around it (brand, product panel) is AuthLayout.
function AuthForm({
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,
footer,
error: errorProp = null,
forgotPassword,
embed,
surface = "card",
className,
...props
}: AuthFormProps) {
const { slot, Header, Title, Description } =
embed ?? (surface === "bare" ? bareParts : cardParts)
const [pendingMethod, setPendingMethod] = React.useState<AuthMethod | null>(null)
// A rejected handler's message. The caller's `error` shows when there is
// none, so a thrown failure is never hidden behind a stale returned one.
const [thrownError, setThrownError] = React.useState<string | null>(null)
const error = thrownError ?? errorProp
// 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(`[${slot}] provider buttons are rendered but onProviderContinue is not set.`)
if (showPasskey && !onPasskeyContinue)
console.error(`[${slot}] the passkey button is rendered but onPasskeyContinue is not set.`)
if (showEmail && !onEmailContinue)
console.error(`[${slot}] the email form is rendered but onEmailContinue is not set.`)
}, [slot, 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
setThrownError(null)
let result: void | Promise<void> | undefined
try {
result = callback()
} catch (err) {
setThrownError(errorMessage(err))
return
}
if (result && typeof result.then === "function") {
setPendingMethod(method)
result.then(
() => setPendingMethod(null),
(err: unknown) => {
setPendingMethod(null)
setThrownError(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
const body = (
<>
<Header>
<Title>{title}</Title>
{/* 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">
<Description>{description}</Description>
{/* 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 dialog 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-large ease-large motion-reduce:transition-none",
error ? "grid-rows-[1fr]" : "grid-rows-[0fr]"
)}
>
<div className="overflow-hidden">
<p
data-slot={`${slot}-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>
</Header>
{hasMethodStack && (
<div data-slot={`${slot}-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={`${slot}-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">
{forgotPassword != null ? (
// The row is as tall as the Label's text line, so the
// label-to-field gap stays the 8px every other field has.
<div className="flex items-baseline justify-between gap-3">
<Label htmlFor={passwordId}>Password</Label>
<span
data-slot={`${slot}-forgot-password`}
// Button's `link` look, copied token for token, so a
// plain anchor or router Link needs no classes.
className="text-sm leading-none [&_a]:rounded-sm [&_a]:text-muted-foreground [&_a]:outline-none [&_a]:transition-colors [&_a]:duration-small [&_a]:ease-small [&_a]:hover:text-foreground [&_a]:focus-visible:ring-[3px] [&_a]:focus-visible:ring-ring-focus"
>
{forgotPassword}
</span>
</div>
) : (
<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={`${slot}-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>
)}
</>
)
if (embed) return body
// Bare carries the Card body's terms minus the inset: the page gives the
// edges, and the blocks keep the `heading-md` rung between them.
if (surface === "bare") {
return (
<div
data-slot="auth-form"
data-surface="bare"
className={cn(
"grid w-full max-w-sm gap-(--spacing-rhythm-heading-md)",
className
)}
{...props}
>
{body}
</div>
)
}
return (
<Card
data-slot="auth-form"
className={cn("w-full max-w-sm", className)}
{...props}
>
{/* The body is the Dialog popup's interior, on the same terms: 24px
inset and the `heading-md` rung between blocks, so the form reads
the same standing on a page as it does in AuthDialog. */}
<div
data-slot="auth-form-body"
className="grid gap-(--spacing-rhythm-heading-md) p-6"
>
{body}
</div>
</Card>
)
}
export { AuthForm }
export type { AuthFormProps, AuthFormOptions, AuthFormEmbed, 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 }