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.

Welcome back

Sign in to your craft-bits account

Don't have an account? Sign up
  • Use any email + the password "craft" to sign in.

Installation

npx shadcn@latest add https://craftbits.dev/r/login-form.json

Usage

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

  1. Provider-agnostic. The component never imports an auth SDK. It hands the caller { email, password } and reacts to the return value: void for success, a string for an inline error, a thrown error for the generic invalid-credentials fallback. All transport (Convex action, REST POST, OAuth pre-flight) lives in caller code.
  2. Native validation comes free. The email input uses type="email" and both inputs are required. The browser blocks the submit + shows its own bubble before onSubmit ever fires, so the callback only runs on a well-formed pair.
  3. One source of truth for loading. loading is the controlled override; if it's undefined the component tracks its own internal flag for the duration of the onSubmit promise. The submit button reads the merged value, so passing loading from the outside (OAuth redirect, parent retry) keeps the disabled state aligned without prop drilling.
  4. Error region is always mounted. A role="alert" element with aria-live="polite" sits between the description and the inputs so screen readers announce errors without re-mounting the form. Empty state fades to opacity-0 to keep the layout stable across the error / no-error transition.
  5. Slots, not hardcoded links. "Forgot password?" and the footer line ("Don't have an account?") are caller-owned ReactNode slots. Pass anything — a <Link> from your router, a <button> that opens a modal, or nothing at all.

Props

PropTypeDefaultDescription
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.
titleReactNode"Welcome back"Heading rendered above the form. Pass null to hide.
descriptionReactNode"Sign in to your account"Sub-heading rendered under the title. Pass null to hide.
submitLabelReactNode"Sign in"Label on the submit button.
loadingLabelReactNode"Please wait…"Label shown while onSubmit is in flight.
emailLabelReactNode"Email"Label on the email input.
emailPlaceholderstring"[email protected]"Placeholder for the email input.
passwordLabelReactNode"Password"Label on the password input.
forgotPasswordSlotReactNodeOptional node rendered next to the password label.
footerSlotReactNodeOptional node rendered below the submit button.
loadingbooleanForce the form into the loading state from the outside.
disabledbooleanfalseDisable every control without resolving.
defaultEmailstring""Initial value for the email input (uncontrolled).
aria-labelstring"Sign in"Accessible name for the <form> element.
classNamestringMerged 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 / id via useId(), plus autoComplete="email" and autoComplete="current-password" so password managers light up.
  • The error region is a role="alert" with aria-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 via aria-describedby so the announcement targets the right control.
  • The submit button is a real <button type="submit"> with min-h-[44px] to clear the WCAG 2.5.8 touch-target floor. While loading, aria-busy is set on the form and the button is disabled.
  • Disabled inputs drop interactivity but stay focusable for screen reader review.
  • Reduced-motion users get the same transition pipeline — only opacity + colour change, no transform keyframes.

Credits

  • Extracted from: algoflashcards (src/platform/ui/login-form.tsx). The source coupled the form to @convex-dev/auth/react's useAuthActions() 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 generic onSubmit({ email, password }) callback with optional forgotPasswordSlot + footerSlot plugs.