Skip to content
Neon UI
Esc
navigateopen⌘Jpreview
On this page

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.

Welcome back

Sign in to continue to your workspace.

New here?

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.

Welcome back

Sign in to continue to your workspace.

New here?

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.
  • BusyisBusy locks everything and the working label shimmers (Sign inSigning in…).
  • Errorerror takes over the description slot with role="alert"; fieldErrors pin to fields until you clear them.
  • Sign upmode="sign-up" adds the name field, retitles the card, flips the footer link, and requires 8+ character passwords.
  • Resetmode="reset" shows the email field alone with reset copy; wire onForgotPassword to switch into it. Once your API sends the link, set resetSentTo and the form flips to its confirmation face: a drawn check, “Check your email”, and an optional onResend link. 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

PropType
onSubmit(values: AuthFormValues) => void

Called with { email, password, name? } — name only in sign-up mode.

Type(values: AuthFormValues) => void
mode?"sign-in" | "sign-up" | "reset"
Type"sign-in" | "sign-up" | "reset"
Default"sign-in"
onModeChange?(mode: AuthMode) => void

Renders the footer swap link and enables mode switching.

Type(mode: AuthMode) => void
isBusy?boolean

Locks the form and puts the action in its working state.

Typeboolean
Defaultfalse
error?string | null

Form-level failure; takes over the description slot in place.

Typestring | null
fieldErrors?Partial<Record<AuthFieldName, string>>

Server-side verdicts pinned to fields (e.g. incorrect password).

TypePartial<Record<AuthFieldName, string>>
resetSentTo?string | null

The email a reset link was sent to; flips reset mode to its confirmation face.

Typestring | null
onResend?() => void

Renders the send-again link on the confirmation face.

Type() => void
validators?Partial<Record<AuthFieldName, AuthValidator>>

Per-field validators merged over the mode defaults — return the failure message, or null when the value passes.

TypePartial<Record<AuthFieldName, AuthValidator>>
variant?"card" | "bare"

card draws the house surface; bare renders naked for split layouts.

Type"card" | "bare"
Default"card"
providers?AuthProvider[]

OAuth-style providers ({ id, label, icon? }) rendered under the divider.

TypeAuthProvider[]
onProvider?(id: string) => void

Called with the provider id.

Type(id: string) => void
onForgotPassword?() => void

Renders the forgot link on the password row (sign-in only).

Type() => void
mark?ReactNode

Brand mark slot above the title.

TypeReactNode
title?string

Overrides the per-mode title.

Typestring
description?string

Overrides the per-mode description.

Typestring

Accessibility

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.