neon-ui
componentauth-formsource: ok · hit
AuthForm
The email/password card: mode switching, providers, structured errors, and a working state — Better Auth-shaped.
npx shadcn add "https://ui.neon.com/r/auth-form.json"free
Where it goes
stats
Interaction
animated-counter
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lightRegistry dependencies
utilshttps://ui.neon.com/r/neon-tokens.jsonhttps://ui.neon.com/r/button.json
Files1 · source cached 10/13/2026, 3:53:32 PM
src/components/auth-form/auth-form.tsx
"use client";
import type { ComponentProps, FocusEvent, FormEvent, ReactNode } from "react";
import { useEffect, useId, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export type AuthMode = "sign-in" | "sign-up" | "reset";
export type AuthFieldName = "name" | "email" | "password";
/** Returns the failure message, or null when the value passes. */
export type AuthValidator = (value: string) => string | null;
export interface AuthProvider {
id: string;
label: string;
icon?: ReactNode;
}
export interface AuthFormValues {
email: string;
password: string;
/** Present in sign-up mode. */
name?: string;
}
export type AuthFormProps = Omit<ComponentProps<"form">, "onSubmit"> & {
/** Which face the form shows. */
mode?: AuthMode;
/** Called with the field values once they pass validation. */
onSubmit: (values: AuthFormValues) => void;
/** Renders the footer swap link and enables mode switching. */
onModeChange?: (mode: AuthMode) => void;
/** Locks the fields and puts the action in its working state. */
isBusy?: boolean;
/** Structured failure message rendered above the action. */
error?: string | null;
/** Server-side verdicts pinned to fields (e.g. incorrect password). */
fieldErrors?: Partial<Record<AuthFieldName, string>>;
/**
* Per-field validators merged over the mode defaults — return the
* failure message, or null when the value passes. Use it to bring
* your own password policy.
*/
validators?: Partial<Record<AuthFieldName, AuthValidator>>;
/** OAuth-style providers rendered under the divider. */
providers?: AuthProvider[];
/** Called with the provider id. */
onProvider?: (id: string) => void;
/** Renders the forgot link on the password row (sign-in only). */
onForgotPassword?: () => void;
/**
* The email a reset link was sent to. In reset mode this flips the
* form to its confirmation face.
*/
resetSentTo?: string | null;
/** Renders the "send again" link on the confirmation face. */
onResend?: () => void;
/** Brand mark slot above the title. */
mark?: ReactNode;
title?: string;
description?: string;
/**
* "card" (default) draws the house surface — border, bg-card,
* padding. "bare" renders naked for split layouts that supply
* their own panel.
*/
variant?: "card" | "bare";
};
/* ─────────────────────────────────────────────────────────
* ENTRANCE STORYBOARD
*
* Read top-to-bottom. Each value is ms after mount; every
* group rises 8px and fades in over 500ms, fields one at a
* time — the form introduces itself in reading order.
* Static under reduced motion.
*
* 0ms mark, title, description
* 80ms first field (then +60ms per field)
* 260ms primary action
* 340ms divider, providers, footer
* ───────────────────────────────────────────────────────── */
const TIMING: Record<
"header" | "fields" | "fieldStagger" | "action" | "meta",
number
> = {
// primary action
action: 260,
// ms between each field
fieldStagger: 60,
// first field
fields: 80,
// mark, title, description
header: 0,
// divider, providers, footer
meta: 340,
};
const RISE =
"fill-mode-backwards fade-in-0 slide-in-from-bottom-2 animate-in duration-500 motion-reduce:animate-none";
/* ─────────────────────────────────────────────────────────
* FIELD STORYBOARD — "light in a tube"
*
* The frame stays neutral; the light does the talking.
*
* focus a primary beam sweeps along the field's bottom
* edge, left to right (250ms strong ease-out,
* transform-only) and the label warms to
* foreground
* blur the beam withdraws; a filled field validates on
* leave — never while you're still typing, and
* never for a field you merely tabbed past
* (required verdicts wait for submit)
* invalid the whole box takes the verdict — destructive
* border and a whisper of destructive fill — the
* message takes over the label slot in place
* (crossfade, no layout shift — the frame never
* moves), and an X draws itself into the field
* edge
* valid a check draws itself into the field edge,
* stroke first to last (300ms ease-out)
* edit any verdict clears instantly — the system
* responds, it doesn't linger
* submit all fields judged at once; the first failure
* takes focus; onSubmit fires only on a clean
* pass. Server fieldErrors pin until edited.
* ───────────────────────────────────────────────────────── */
const EASE_OUT = "cubic-bezier(0.23, 1, 0.32, 1)";
const EMAIL_SHAPE = /^\S+@\S+\.\S+$/u;
/* Better Auth's server defaults — client verdicts agree with them. */
const MIN_PASSWORD = 8;
const MAX_PASSWORD = 128;
export const validateEmail = (value: string): string | null => {
if (!value) {
return "Add your email.";
}
return EMAIL_SHAPE.test(value) ? null : "Not a valid email.";
};
const VALIDATORS: Record<
AuthMode,
Partial<Record<AuthFieldName, (value: string) => string | null>>
> = {
reset: {
email: validateEmail,
},
"sign-in": {
email: validateEmail,
password: (value) => (value ? null : "Add your password."),
},
"sign-up": {
email: validateEmail,
name: (value) => (value.trim() ? null : "Add your name."),
password: (value) => {
if (value.length < MIN_PASSWORD) {
return "At least 8 characters.";
}
return value.length > MAX_PASSWORD ? "At most 128 characters." : null;
},
},
};
/* Per-mode copy: sentence case, no exclamation marks. */
const COPY: Record<
AuthMode,
{ title: string; description: string; action: string; working: string }
> = {
reset: {
action: "Send reset link",
description: "Enter your email and we'll send you a reset link.",
title: "Reset your password",
working: "Sending…",
},
"sign-in": {
action: "Sign in",
description: "Sign in to continue to your workspace.",
title: "Welcome back",
working: "Signing in…",
},
"sign-up": {
action: "Create account",
description: "Start building on your own database.",
title: "Create your account",
working: "Creating account…",
},
};
/* ─────────────────────────────────────────────────────────
* ACTION STORYBOARD — "ignition"
*
* dormant ghost: hairline border, muted label — the form
* hasn't earned the color yet
* charged every visible field has content: the action
* fills to neon on a 300ms ramp and a soft
* primary glow blooms
* press scale 0.98, 160ms — the interface is listening
* busy the working label shimmers under lock
* ───────────────────────────────────────────────────────── */
/* The charged CTA speaks through color and the sweep alone — no glow. */
const CTA_GLOW = "";
const AuthFormHeader = ({
description,
error,
mark,
title,
}: {
description: string;
error: string | null;
mark?: ReactNode;
title: string;
}) => (
<div
className={cn("flex flex-col gap-1.5", RISE)}
style={{ animationDelay: `${TIMING.header}ms` }}
>
{mark ? (
<span className="mb-2 text-primary" data-slot="auth-form-mark">
{mark}
</span>
) : null}
<h2 className="text-balance font-semibold text-foreground text-xl tracking-tight">
{title}
</h2>
{/* The form's voice: normally the pitch, on failure the verdict —
swapped in place so the frame never moves. */}
<p
className={cn(
"fade-in-0 animate-in text-pretty text-sm duration-200 motion-reduce:animate-none",
error ? "text-destructive" : "text-muted-foreground"
)}
data-slot={error ? "auth-form-error" : undefined}
key={error ?? "description"}
role={error ? "alert" : undefined}
>
{error ?? description}
</p>
</div>
);
/** Divider, provider buttons, and the mode-swap footer. */
const FIELD_NAMES: Record<AuthMode, AuthFieldName[]> = {
reset: ["email"],
"sign-in": ["email", "password"],
"sign-up": ["name", "email", "password"],
};
/** Header copy resolution: overrides win, then the face speaks. */
const headerCopy = (
copy: (typeof COPY)[AuthMode],
sent: boolean,
resetSentTo: string | null,
title?: string,
description?: string
) => ({
description:
description ??
(sent ? `A reset link is on its way to ${resetSentTo}.` : copy.description),
title: title ?? (sent ? "Check your email" : copy.title),
});
/** The confirmation face: a drawn check and the way back. */
const ResetSentFace = ({
isBusy,
onResend,
}: {
isBusy: boolean;
onResend?: () => void;
}) => (
<div
className={cn("flex flex-col items-center gap-4 py-2", RISE)}
data-slot="auth-form-reset-sent"
style={{ animationDelay: `${TIMING.fields}ms` }}
>
<svg
aria-hidden="true"
className="size-8 text-primary"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
viewBox="0 0 24 24"
>
<path
className="neon-check-draw"
d="M4 12.5 10 18.5 20 6"
pathLength={1}
/>
</svg>
{onResend ? (
<button
className="text-muted-foreground text-xs underline-offset-4 transition-colors hover:text-foreground hover:underline"
disabled={isBusy}
onClick={onResend}
type="button"
>
send again
</button>
) : null}
</div>
);
/* The footer swap per face: each mode offers the way back. */
const META_SWAP: Record<AuthMode, { ask: string; to: AuthMode; go: string }> = {
reset: { ask: "Remembered it?", go: "Sign in", to: "sign-in" },
"sign-in": { ask: "New here?", go: "Create account", to: "sign-up" },
"sign-up": { ask: "Already have an account?", go: "Sign in", to: "sign-in" },
};
const AuthFormMeta = ({
isBusy,
mode,
onModeChange,
onProvider,
providers,
}: {
isBusy: boolean;
mode: AuthMode;
onModeChange?: (mode: AuthMode) => void;
onProvider?: (id: string) => void;
providers?: AuthProvider[];
}) => (
<div
className={cn("flex flex-col gap-4", RISE)}
style={{ animationDelay: `${TIMING.meta}ms` }}
>
{providers?.length ? (
<>
<div
aria-hidden="true"
className="flex items-center gap-3 text-muted-foreground/60"
>
<span className="h-px flex-1 bg-border/60" />
<span className="text-[10px]">or continue with</span>
<span className="h-px flex-1 bg-border/60" />
</div>
<div
className="grid auto-cols-fr grid-flow-col gap-2"
data-slot="auth-form-providers"
>
{providers.map((provider) => (
<Button
className="active:scale-[0.98]"
disabled={isBusy}
key={provider.id}
onClick={() => onProvider?.(provider.id)}
type="button"
variant="outline"
>
{provider.icon}
<span className="text-xs">{provider.label}</span>
</Button>
))}
</div>
</>
) : null}
{onModeChange ? (
<p className="text-center text-muted-foreground text-xs">
{META_SWAP[mode].ask}{" "}
<button
className="text-foreground underline underline-offset-4 transition-colors hover:text-primary"
onClick={() => onModeChange(META_SWAP[mode].to)}
type="button"
>
{META_SWAP[mode].go}
</button>
</p>
) : null}
</div>
);
/* ─────────────────────────────────────────────────────────
* STRENGTH STORYBOARD — sign-up password only
*
* The beam doubles as the meter: its reach grows with the
* password and its color warms from destructive through
* neutral to primary. The word ("weak" / "fair" / "strong")
* rides the trailing slot — nothing changes height.
* Scoring matches Better Auth's defaults (8–128 chars) as
* the floor, then rewards mixed case, digits, symbols, and
* length.
* ───────────────────────────────────────────────────────── */
export interface StrengthMeter {
label: string;
ratio: number;
tone: "weak" | "fair" | "strong";
}
const METER_TONE: Record<StrengthMeter["tone"], string> = {
fair: "bg-foreground/50",
strong: "bg-primary",
weak: "bg-destructive",
};
const METER_WORD: Record<StrengthMeter["tone"], string> = {
fair: "text-muted-foreground",
strong: "text-primary",
weak: "text-destructive",
};
const STRENGTH_STEPS = 5;
const FAIR_FLOOR = 2;
const STRONG_FLOOR = 4;
const LONG_PASSWORD = 12;
export const scorePassword = (value: string): number => {
let score = 0;
if (value.length >= MIN_PASSWORD) {
score += 1;
}
if (value.length >= LONG_PASSWORD) {
score += 1;
}
if (/[a-z]/u.test(value) && /[A-Z]/u.test(value)) {
score += 1;
}
if (/\d/u.test(value)) {
score += 1;
}
if (/[^a-zA-Z0-9]/u.test(value)) {
score += 1;
}
return score;
};
export interface PasswordRequirement {
label: string;
met: boolean;
}
/** The checklist the popover renders while the password field is focused. */
const passwordRequirements = (value: string): PasswordRequirement[] => [
{ label: "8+ characters", met: value.length >= MIN_PASSWORD },
{
label: "upper & lower case",
met: /[a-z]/u.test(value) && /[A-Z]/u.test(value),
},
{ label: "a number", met: /\d/u.test(value) },
{ label: "a symbol", met: /[^a-zA-Z0-9]/u.test(value) },
];
/** Null when empty — the meter only speaks once you've started. */
const strengthMeter = (value: string): StrengthMeter | null => {
if (!value) {
return null;
}
const score = scorePassword(value);
let tone: StrengthMeter["tone"] = "weak";
if (score >= STRONG_FLOOR) {
tone = "strong";
} else if (score >= FAIR_FLOOR) {
tone = "fair";
}
return {
label: tone,
ratio: tone === "strong" ? 1 : Math.max(score / STRENGTH_STEPS, 0.12),
tone,
};
};
const beamTone = (
error: string | null | undefined,
meter: StrengthMeter | null | undefined
) => {
// Invalid verdicts paint the whole box (see FIELD_INPUT usage);
// the beam stands down instead of doubling the signal.
if (error) {
return "scale-x-0 bg-destructive";
}
if (meter) {
return METER_TONE[meter.tone];
}
return "scale-x-0 bg-primary group-focus-within:scale-x-100";
};
/* ─────────────────────────────────────────────────────────
* REQUIREMENTS POPOVER
*
* Portaled to the body and pinned to the input's rect (fixed
* position, re-measured on scroll and resize), so it floats
* above every sibling — no stacking context, not even the
* charged CTA, can paint over it. Each rule flips
* from a muted dot to a drawn primary check as the password
* satisfies it. Floating, so nothing in the form shifts.
* ───────────────────────────────────────────────────────── */
const RequirementsPopover = ({
anchor,
requirements,
}: {
anchor: { current: HTMLInputElement | null };
requirements: PasswordRequirement[];
}) => {
const [rect, setRect] = useState<DOMRect | null>(null);
useEffect(() => {
const measure = () => {
const el = anchor.current;
if (el) {
setRect(el.getBoundingClientRect());
}
};
const frame = requestAnimationFrame(measure);
window.addEventListener("scroll", measure, true);
window.addEventListener("resize", measure);
return () => {
cancelAnimationFrame(frame);
window.removeEventListener("scroll", measure, true);
window.removeEventListener("resize", measure);
};
}, [anchor]);
if (!rect) {
return null;
}
return createPortal(
<div
aria-hidden="true"
className="fade-in-0 slide-in-from-bottom-1 pointer-events-none fixed z-50 flex w-max animate-in flex-col gap-1.5 rounded-md border border-border/60 bg-popover p-3 shadow-lg duration-200 motion-reduce:animate-none"
data-slot="auth-form-requirements"
style={{ left: rect.left, top: rect.bottom + 8 }}
>
{requirements.map((rule) => (
<span
className={cn(
"flex items-center gap-2 text-xs transition-colors duration-200",
rule.met ? "text-foreground" : "text-muted-foreground/70"
)}
data-met={rule.met || undefined}
key={rule.label}
>
{rule.met ? (
<svg
className="size-3 text-primary"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2.5"
viewBox="0 0 24 24"
>
<path
className="neon-check-draw"
d="M4 12.5 10 18.5 20 6"
pathLength={1}
/>
</svg>
) : (
<span className="mx-[5px] size-0.5 rounded-full bg-muted-foreground/70" />
)}
{rule.label}
</span>
))}
</div>,
document.body
);
};
/* The field frame stays neutral in every state — the beam under the
* input and the label carry the verdict. */
const FIELD_INPUT =
"w-full rounded-md border border-border/60 bg-transparent px-3 py-2 text-base caret-primary sm:text-sm outline-none transition-colors placeholder:text-muted-foreground/60 hover:border-border focus:border-border disabled:cursor-not-allowed disabled:opacity-60";
/** A verdict glyph that draws itself in, stroke first to last. */
const DrawnGlyph = ({ kind }: { kind: "check" | "cross" }) => (
<svg
aria-hidden="true"
className={cn(
"-translate-y-1/2 absolute top-1/2 right-3 size-3.5",
kind === "check" ? "text-primary" : "text-destructive"
)}
data-slot={`auth-form-field-${kind}`}
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
>
<path
className="neon-check-draw"
d={kind === "check" ? "M4 12.5 10 18.5 20 6" : "m6 6 12 12m0-12L6 18"}
pathLength={1}
/>
</svg>
);
/** One field: mono label slot that the verdict takes over in place,
* a focus beam, and a drawn check or cross at the field edge. */
const AuthField = ({
autoComplete,
delay,
disabled,
error,
label,
meter,
name,
onEdit,
requirements,
onLeave,
placeholder,
trailing,
type,
valid,
}: {
autoComplete: string;
delay: number;
disabled: boolean;
error?: string | null;
label: string;
meter?: StrengthMeter | null;
name: AuthFieldName;
requirements?: PasswordRequirement[];
onEdit: (field: AuthFieldName) => void;
onLeave: (field: AuthFieldName, value: string) => void;
placeholder: string;
trailing?: ReactNode;
type: string;
valid?: boolean;
}) => {
const messageId = useId();
const anchorRef = useRef<HTMLInputElement>(null);
const [focused, setFocused] = useState(false);
return (
<label
className={cn("group flex flex-col gap-1.5", RISE)}
data-invalid={error ? true : undefined}
data-slot="auth-form-field"
style={{ animationDelay: `${delay}ms` }}
>
{/* The label slot: the verdict takes it over in place — same
row, same height, zero layout shift. */}
<span className="flex items-baseline justify-between">
<span
className={cn(
"fade-in-0 min-w-0 flex-1 animate-in truncate text-xs duration-200 motion-reduce:animate-none",
error
? "text-destructive"
: "text-muted-foreground transition-colors group-focus-within:text-foreground"
)}
id={messageId}
key={error ?? label}
role={error ? "alert" : undefined}
>
{error ?? label}
</span>
{trailing}
</span>
<span className="relative">
<input
aria-describedby={error ? messageId : undefined}
aria-invalid={error ? true : undefined}
autoComplete={autoComplete}
className={cn(
FIELD_INPUT,
(valid || error) && "pr-9",
// An invalid verdict tints the whole box, not just an
// underline — border and a whisper of fill.
error &&
"border-destructive/60 bg-destructive/[0.04] hover:border-destructive/70 focus:border-destructive/70"
)}
disabled={disabled}
name={name}
onBlur={(event: FocusEvent<HTMLInputElement>) => {
setFocused(false);
onLeave(name, event.target.value);
}}
onChange={() => onEdit(name)}
onFocus={() => setFocused(true)}
placeholder={placeholder}
ref={anchorRef}
type={type}
/>
{/* The beam: primary light sweeps in on focus; a verdict
relights it in destructive and keeps it lit. With a
strength meter, the beam's reach IS the reading. */}
<span
aria-hidden="true"
className={cn(
"pointer-events-none absolute inset-x-px bottom-0 h-[1.5px] origin-left motion-reduce:transition-none",
beamTone(error, meter)
)}
data-slot="auth-form-field-beam"
style={{
transition: `scale 250ms ${EASE_OUT}, background-color 250ms ease`,
...(meter && !error && { scale: `${meter.ratio} 1` }),
}}
/>
{valid ? <DrawnGlyph kind="check" /> : null}
{error ? <DrawnGlyph kind="cross" /> : null}
{requirements && focused ? (
<RequirementsPopover anchor={anchorRef} requirements={requirements} />
) : null}
</span>
</label>
);
};
/** Sign up shows the strength reading; sign in shows the forgot link. */
const passwordTrailing = (
signUp: boolean,
meter: StrengthMeter | null,
onForgotPassword?: () => void
): ReactNode => {
if (signUp) {
return meter ? (
<span
aria-live="polite"
className={cn(
"fade-in-0 animate-in font-mono text-xs duration-200 motion-reduce:animate-none",
METER_WORD[meter.tone]
)}
data-slot="auth-form-strength"
key={meter.tone}
>
{meter.label}
</span>
) : undefined;
}
return onForgotPassword ? (
<button
className="text-muted-foreground text-xs underline-offset-4 transition-colors hover:text-foreground hover:underline"
onClick={onForgotPassword}
type="button"
>
Forgot password?
</button>
) : undefined;
};
/** The per-mode field stack; hidden (not unmounted) on the sent face. */
const AuthFormFields = ({
hidden,
isBusy,
meter,
mode,
onEdit,
onForgotPassword,
onLeave,
requirements,
verdictFor,
}: {
hidden: boolean;
isBusy: boolean;
meter: StrengthMeter | null;
mode: AuthMode;
onEdit: (field: AuthFieldName) => void;
onForgotPassword?: () => void;
onLeave: (field: AuthFieldName, value: string) => void;
requirements?: PasswordRequirement[];
verdictFor: (field: AuthFieldName) => {
error: string | null;
valid: boolean;
};
}) => {
const signUp = mode === "sign-up";
const reset = mode === "reset";
return (
<div
className={cn("flex flex-col gap-4", hidden && "hidden")}
key={`${mode}-${String(hidden)}`}
>
{signUp ? (
<AuthField
autoComplete="name"
delay={TIMING.fields}
disabled={isBusy}
label="name"
name="name"
onEdit={onEdit}
onLeave={onLeave}
placeholder="Ada Lovelace"
type="text"
{...verdictFor("name")}
/>
) : null}
<AuthField
autoComplete="email"
delay={TIMING.fields + (signUp ? TIMING.fieldStagger : 0)}
disabled={isBusy}
label="email"
name="email"
onEdit={onEdit}
onLeave={onLeave}
placeholder="you@example.com"
type="email"
{...verdictFor("email")}
/>
{reset ? null : (
<AuthField
autoComplete={signUp ? "new-password" : "current-password"}
delay={TIMING.fields + TIMING.fieldStagger * (signUp ? 2 : 1)}
disabled={isBusy}
label="password"
meter={meter}
name="password"
onEdit={onEdit}
onLeave={onLeave}
placeholder="••••••••"
requirements={requirements}
trailing={passwordTrailing(signUp, meter, onForgotPassword)}
type="password"
{...verdictFor("password")}
/>
)}
</div>
);
};
type Verdicts = Partial<
Record<AuthFieldName, { error: string | null; valid: boolean }>
>;
/** Server verdicts pin first; local blur/submit verdicts follow. */
const resolveVerdict = (
field: AuthFieldName,
verdicts: Verdicts,
fieldErrors?: Partial<Record<AuthFieldName, string>>
) => {
const server = fieldErrors?.[field];
const local = verdicts[field];
return {
error: server ?? local?.error ?? null,
valid: !server && local?.valid === true,
};
};
/** The ignition action. */
const AuthFormAction = ({
charged,
isBusy,
label,
working,
}: {
charged: boolean;
isBusy: boolean;
label: string;
working: string;
}) => (
<div
className={cn("flex flex-col gap-3", RISE)}
style={{ animationDelay: `${TIMING.action}ms` }}
>
<Button
className={cn(
"w-full transition-[background-color,color,border-color,box-shadow,scale] duration-300 active:scale-[0.98] motion-reduce:active:scale-100",
charged || isBusy
? cn(CTA_GLOW, "disabled:opacity-100")
: "border border-border/60 bg-transparent text-muted-foreground hover:bg-transparent hover:text-foreground"
)}
data-charged={charged || undefined}
disabled={isBusy}
type="submit"
>
{/* animate-in and shimmer both own the animation shorthand, so
the shimmer rides an inner span. */}
<span className="fade-in-0 animate-in duration-300" key={String(isBusy)}>
<span
className={cn("block", { "shimmer shimmer-duration-2400": isBusy })}
>
{isBusy ? working : label}
</span>
</span>
</Button>
</div>
);
/** Judge every visible field at once; report the first failure. */
const judgeAll = (
rules: Partial<Record<AuthFieldName, AuthValidator>>,
fields: AuthFieldName[],
data: FormData
) => {
const verdicts: Verdicts = {};
let firstFailure: AuthFieldName | null = null;
for (const field of fields) {
const check = rules[field];
if (!check) {
continue;
}
const value = String(data.get(field) ?? "");
const failure = check(value);
verdicts[field] = { error: failure, valid: !failure && value.length > 0 };
if (failure && !firstFailure) {
firstFailure = field;
}
}
return { firstFailure, verdicts };
};
export const AuthForm = ({
className,
description,
error = null,
fieldErrors,
isBusy = false,
mark,
mode = "sign-in",
onForgotPassword,
onModeChange,
onResend,
onProvider,
onSubmit,
providers,
resetSentTo = null,
title,
validators,
variant = "card",
...props
}: AuthFormProps) => {
const copy = COPY[mode];
const signUp = mode === "sign-up";
const reset = mode === "reset";
const sent = reset && Boolean(resetSentTo);
const rules = { ...VALIDATORS[mode], ...validators };
const [judged, setJudged] = useState<{ mode: AuthMode; verdicts: Verdicts }>({
mode,
verdicts: {},
});
const [charged, setCharged] = useState(false);
const [password, setPassword] = useState("");
// Render-time re-seed: flipping modes clears every verdict.
if (judged.mode !== mode) {
setJudged({ mode, verdicts: {} });
setCharged(false);
setPassword("");
}
const fieldNames = FIELD_NAMES[mode];
const verdictFor = (field: AuthFieldName) =>
resolveVerdict(field, judged.verdicts, fieldErrors);
const judge = (field: AuthFieldName, value: string) => {
const check = rules[field];
// Skipping past an empty field isn't a mistake yet — required
// verdicts wait for submit.
if (!(check && value)) {
return;
}
const failure = check(value);
setJudged((prev) => ({
mode,
verdicts: {
...prev.verdicts,
[field]: { error: failure, valid: !failure && value.length > 0 },
},
}));
};
const clear = (field: AuthFieldName) => {
setJudged((prev) => ({
mode,
verdicts: { ...prev.verdicts, [field]: undefined },
}));
};
/** The action charges once every visible field has content. */
const handleFormChange = (event: FormEvent<HTMLFormElement>) => {
const data = new FormData(event.currentTarget);
setCharged(
fieldNames.every((field) => String(data.get(field) ?? "").length > 0)
);
setPassword(String(data.get("password") ?? ""));
};
const meter = signUp ? strengthMeter(password) : null;
const requirements = signUp ? passwordRequirements(password) : undefined;
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (isBusy) {
return;
}
const form = event.currentTarget;
const data = new FormData(form);
const values: AuthFormValues = {
email: String(data.get("email") ?? ""),
password: String(data.get("password") ?? ""),
...(signUp && { name: String(data.get("name") ?? "") }),
};
if (sent) {
return;
}
// Judge everything at once; the first failure takes focus.
const { firstFailure, verdicts } = judgeAll(rules, fieldNames, data);
setJudged({ mode, verdicts });
if (firstFailure) {
form.querySelector<HTMLInputElement>(`[name="${firstFailure}"]`)?.focus();
return;
}
onSubmit(values);
};
return (
<form
className={cn(
"flex w-full max-w-sm flex-col gap-6",
variant === "card" &&
"rounded-lg border border-border/60 bg-card p-6 transition-colors hover:border-border sm:p-8",
className
)}
data-busy={isBusy || undefined}
data-mode={mode}
data-slot="auth-form"
noValidate
onChange={handleFormChange}
onSubmit={handleSubmit}
{...props}
>
<AuthFormHeader
error={error}
mark={mark}
{...headerCopy(copy, sent, resetSentTo, title, description)}
/>
{/* The confirmation face: the fields yield — the form's work is
done, the inbox's begins. */}
{sent ? <ResetSentFace isBusy={isBusy} onResend={onResend} /> : null}
{/* Fields crossfade when the mode flips, entering one at a time. */}
<AuthFormFields
hidden={sent}
isBusy={isBusy}
meter={meter}
mode={mode}
onEdit={clear}
onForgotPassword={onForgotPassword}
onLeave={judge}
requirements={requirements}
verdictFor={verdictFor}
/>
{sent ? null : (
<AuthFormAction
charged={charged}
isBusy={isBusy}
label={copy.action}
working={copy.working}
/>
)}
{(providers?.length || onModeChange) && (
<AuthFormMeta
isBusy={isBusy}
mode={mode}
onModeChange={onModeChange}
onProvider={onProvider}
providers={reset ? undefined : providers}
/>
)}
</form>
);
};