AuthForm
The email/password card with providers, mode switching, and structured errors.
AuthForm is the platform’s front gate: an email/password card in the Neon language — mono labels, neutral frames, a neon caret, and color reserved for the light. On focus a primary beam sweeps along the field’s bottom edge; fields validate on first blur (never while you type) and the verdict lands in place: the message takes over the label slot, a check or cross draws itself into the field edge, and the beam relights — nothing in the form ever changes height, so a shader cover panel beside it never resizes. The action stays a ghost until every field has content, then ignites with the house glow. One mode prop flips sign in and sign up, API failures arrive through error (spoken through the description slot) or fieldErrors (pinned to a field), and on mount the form introduces itself in reading order — one quiet 8px cascade, static under reduced motion.
Install
npx shadcn@latest add https://ui.neon.com/r/auth-form.json
Usage
import { AuthForm } from "@/components/neon-ui/auth-form";
<AuthForm
mode={mode}
onModeChange={setMode}
isBusy={isPending}
error={error}
onSubmit={(values) => authClient.signIn.email(values)}
/>;
Wiring to Neon Auth
Neon Auth is built on Better Auth, and the form maps onto its client 1 — AuthFormValues matches the email methods, provider ids are social slugs, and API failures route to error or fieldErrors:
const handleSubmit = async (values: AuthFormValues) => {
setBusy(true);
const { error } =
mode === "sign-up"
? await authClient.signUp.email({
email: values.email,
name: values.name ?? "",
password: values.password,
})
: await authClient.signIn.email(values);
setBusy(false);
if (error?.code === "INVALID_EMAIL_OR_PASSWORD") {
setFieldErrors({ password: "Incorrect email or password." });
} else if (error) {
setError(error.message ?? "Something went wrong.");
}
};
<AuthForm
mode={mode}
onModeChange={setMode}
onProvider={(id) => authClient.signIn.social({ provider: id })}
onSubmit={handleSubmit}
providers={[
{ id: "github", label: "GitHub" },
{ id: "google", label: "Google" },
]}
/>;
The default sign-up password rule (8–128 characters) mirrors Better Auth’s server defaults, so client verdicts and server verdicts agree.
With the aurora cover
The reason NeonAurora exists: use it as the cover panel of a split sign-in screen. The aurora fills the left half, the form sits on bg-card to the right, and the whole screen stays two theme tokens away from any brand.
States
- Focus — a primary beam sweeps under the field (250ms ease-out, transform-only) and the label warms.
- Invalid — a filled field is judged when you leave it; empty fields wait for submit (tabbing past isn’t a mistake yet). The message replaces the label in place, a cross draws in at the field edge, and the beam relights in destructive. Editing clears the verdict instantly.
- Valid — a check draws itself into the field edge, stroke first to last.
- Charged — once every visible field has content the action fills to neon with a soft glow; before that it stays a quiet ghost.
- Busy —
isBusylocks everything and the working label shimmers (Sign in→Signing in…). - Error —
errortakes over the description slot withrole="alert";fieldErrorspin to fields until you clear them. - Sign up —
mode="sign-up"adds the name field, retitles the card, flips the footer link, and requires 8+ character passwords. - Reset —
mode="reset"shows the email field alone with reset copy; wireonForgotPasswordto switch into it. Once your API sends the link, setresetSentToand the form flips to its confirmation face: a drawn check, “Check your email”, and an optionalonResendlink. The footer always offers the way back to sign in.
No state changes the form’s height — every verdict animates in place.
Validation
Each mode ships sensible defaults: email shape on both, name required and 8+ character passwords on sign up. Override any field with validators — return the failure message, or null when the value passes:
<AuthForm
mode="sign-up"
onSubmit={handleSubmit}
validators={{
password: (value) =>
/^(?=.*\d)(?=.*[a-z])(?=.*[A-Z]).{12,}$/u.test(value)
? null
: "12+ characters with a number and both cases.",
}}
/>
Keep messages short — they render in the label slot, one line.
On sign up the password field also opens a floating requirements popover while focused — 8+ characters, mixed case, a number, a symbol — each rule flipping from a muted dot to a drawn check as the password satisfies it. It floats below the field, so nothing shifts.
On sign up the password field carries a strength meter: the beam under the field grows with the password and warms from destructive through neutral to primary, and the reading (weak / fair / strong) rides the trailing slot. Scoring uses Better Auth’s 8–128 character defaults as the floor, then rewards mixed case, digits, symbols, and length.
Props
onSubmit(values: AuthFormValues) => void
Called with { email, password, name? } — name only in sign-up mode.
(values: AuthFormValues) => voidmode?"sign-in" | "sign-up" | "reset"
"sign-in" | "sign-up" | "reset""sign-in"onModeChange?(mode: AuthMode) => void
Renders the footer swap link and enables mode switching.
(mode: AuthMode) => voidisBusy?boolean
Locks the form and puts the action in its working state.
booleanfalseerror?string | null
Form-level failure; takes over the description slot in place.
string | nullfieldErrors?Partial<Record<AuthFieldName, string>>
Server-side verdicts pinned to fields (e.g. incorrect password).
Partial<Record<AuthFieldName, string>>resetSentTo?string | null
The email a reset link was sent to; flips reset mode to its confirmation face.
string | nullonResend?() => void
Renders the send-again link on the confirmation face.
() => voidvalidators?Partial<Record<AuthFieldName, AuthValidator>>
Per-field validators merged over the mode defaults — return the failure message, or null when the value passes.
Partial<Record<AuthFieldName, AuthValidator>>variant?"card" | "bare"
card draws the house surface; bare renders naked for split layouts.
"card" | "bare""card"providers?AuthProvider[]
OAuth-style providers ({ id, label, icon? }) rendered under the divider.
AuthProvider[]onProvider?(id: string) => void
Called with the provider id.
(id: string) => voidonForgotPassword?() => void
Renders the forgot link on the password row (sign-in only).
() => voidmark?ReactNode
Brand mark slot above the title.
ReactNodetitle?string
Overrides the per-mode title.
stringdescription?string
Overrides the per-mode description.
stringAccessibility
Labels are real <label> elements wrapping their inputs; autocomplete hints follow the mode (current-password vs new-password); the error panel announces via role="alert"; every secondary action is a real <button type="button">; the entrance cascade respects prefers-reduced-motion.