Login Form
A minimal email + password sign-in form. Owns its own input + loading state, runs the browser's native validation (required + type="email"), and bubbles the typed credentials up through onSubmit. Provider-agnostic — wire it to Convex Auth, Auth.js, Clerk, a custom JWT endpoint, or a mock for tests.
- Use any email + the password "craft" to sign in.
Installation
npx shadcn@latest add https://craftbits.dev/r/login-form.jsonUsage
The simplest case — handle credentials yourself, throw on failure:
import { LoginForm } from "@craft-bits/core";
<LoginForm
onSubmit={async ({ email, password }) => {
const res = await fetch("/api/login", {
method: "POST",
body: JSON.stringify({ email, password }),
});
if (!res.ok) throw new Error("invalid");
}}
/>Return a string from onSubmit to display an inline error instead of the default "Invalid email or password." copy:
<LoginForm
onSubmit={async ({ email, password }) => {
const result = await signIn({ email, password });
if (result.status === "locked") return "This account is locked.";
if (result.status === "wrong") return "Email or password is wrong.";
}}
/>Compose with forgotPasswordSlot and footerSlot to extend without forking:
<LoginForm
title="Welcome back"
description="Sign in to your account"
forgotPasswordSlot={<a href="/reset">Forgot password?</a>}
footerSlot={
<>
No account? <a href="/signup">Sign up</a>
</>
}
onSubmit={signIn}
/>External loading control — pass loading to keep the button disabled across a parent-owned async flow (e.g. an OAuth redirect):
<LoginForm loading={pendingRedirect} onSubmit={signIn} />Understanding the component
- Provider-agnostic. The component never imports an auth SDK. It hands the caller
{ email, password }and reacts to the return value:voidfor success, a string for an inline error, a thrown error for the generic invalid-credentials fallback. All transport (Convex action, RESTPOST, OAuth pre-flight) lives in caller code. - Native validation comes free. The email input uses
type="email"and both inputs arerequired. The browser blocks the submit + shows its own bubble beforeonSubmitever fires, so the callback only runs on a well-formed pair. - One source of truth for loading.
loadingis the controlled override; if it'sundefinedthe component tracks its own internal flag for the duration of theonSubmitpromise. The submit button reads the merged value, so passingloadingfrom the outside (OAuth redirect, parent retry) keeps the disabled state aligned without prop drilling. - Error region is always mounted. A
role="alert"element witharia-live="polite"sits between the description and the inputs so screen readers announce errors without re-mounting the form. Empty state fades toopacity-0to keep the layout stable across the error / no-error transition. - Slots, not hardcoded links. "Forgot password?" and the footer line ("Don't have an account?") are caller-owned
ReactNodeslots. Pass anything — a<Link>from your router, a<button>that opens a modal, or nothing at all.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
onSubmit | ({ email, password }) => void | string | Promise<void | string> | — | Fired with typed credentials after native validation passes. Return a string to set an inline error; throw / reject to fall back to the generic copy. |
title | ReactNode | "Welcome back" | Heading rendered above the form. Pass null to hide. |
description | ReactNode | "Sign in to your account" | Sub-heading rendered under the title. Pass null to hide. |
submitLabel | ReactNode | "Sign in" | Label on the submit button. |
loadingLabel | ReactNode | "Please wait…" | Label shown while onSubmit is in flight. |
emailLabel | ReactNode | "Email" | Label on the email input. |
emailPlaceholder | string | "[email protected]" | Placeholder for the email input. |
passwordLabel | ReactNode | "Password" | Label on the password input. |
forgotPasswordSlot | ReactNode | — | Optional node rendered next to the password label. |
footerSlot | ReactNode | — | Optional node rendered below the submit button. |
loading | boolean | — | Force the form into the loading state from the outside. |
disabled | boolean | false | Disable every control without resolving. |
defaultEmail | string | "" | Initial value for the email input (uncontrolled). |
aria-label | string | "Sign in" | Accessible name for the <form> element. |
className | string | — | Merged onto the rendered <form>. |
Accessibility
- The root is a
<form aria-label="Sign in">so screen readers announce the form as a named landmark. - Inputs are wired with
htmlFor/idviauseId(), plusautoComplete="email"andautoComplete="current-password"so password managers light up. - The error region is a
role="alert"witharia-live="polite", always mounted so announcements are immediate and the layout is stable across the empty / error states. - When an error is present, both inputs flip
aria-invalid="true"and reference the error region viaaria-describedbyso the announcement targets the right control. - The submit button is a real
<button type="submit">withmin-h-[44px]to clear the WCAG 2.5.8 touch-target floor. While loading,aria-busyis set on the form and the button isdisabled. - Disabled inputs drop interactivity but stay focusable for screen reader review.
- Reduced-motion users get the same transition pipeline — only opacity + colour change, no
transformkeyframes.
Credits
- Extracted from:
algoflashcards(src/platform/ui/login-form.tsx). The source coupled the form to@convex-dev/auth/react'suseAuthActions()and hardcoded GitHub / Google / Guest OAuth buttons, a product-specific headline, an absolute cover image, and a Terms / Privacy footer. craft-bits strips the auth-provider dependency, drops the OAuth row + cover image (callers compose those externally), and turns the only mandatory behaviour into a genericonSubmit({ email, password })callback with optionalforgotPasswordSlot+footerSlotplugs.