Signup Form

A minimal email + password + confirm-password sign-up form. Owns its own input + loading + error state, runs the browser's native validation (required + type="email") plus a password-match and minimum-length check, then 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.

Create your account

Pick an email — anything >= 8 chars for the password.

Must be at least 8 characters.

Already have an account? Sign in

Installation

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

Usage

The simplest case — handle credentials yourself, throw on failure:

import { SignupForm } from "@craft-bits/core";
 
<SignupForm
  onSubmit={async ({ email, password }) => {
    const res = await fetch("/api/signup", {
      method: "POST",
      body: JSON.stringify({ email, password }),
    });
    if (!res.ok) throw new Error("could not create account");
  }}
/>

Return a string from onSubmit to display an inline error instead of the default "Could not create account." copy:

<SignupForm
  onSubmit={async ({ email, password }) => {
    const result = await createAccount({ email, password });
    if (result.status === "taken") return "That email is already in use.";
    if (result.status === "weak") return "Pick a stronger password.";
  }}
/>

Compose with footerSlot to extend without forking:

<SignupForm
  title="Create your account"
  description="Start your free trial — no card required."
  footerSlot={
    <>
      Already have an account? <a href="/login">Sign in</a>
    </>
  }
  onSubmit={createAccount}
/>

External loading control — pass loading to keep the button disabled across a parent-owned async flow (e.g. an OAuth redirect):

<SignupForm loading={pendingRedirect} onSubmit={createAccount} />

Understanding the component

  1. Provider-agnostic. The component never imports an auth SDK. It hands the caller { email, password, confirmPassword } and reacts to the return value: void for success, a string for an inline error, a thrown error for the generic could-not-create-account fallback. All transport (Convex action, REST POST, OAuth pre-flight) lives in caller code.
  2. Two layers of validation, in order. The browser blocks the submit on the required / type="email" rules; then the component checks password === confirmPassword and password.length >= minPasswordLength before onSubmit fires. The caller never sees malformed credentials.
  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.
  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.
  5. Slots, not hardcoded links. The footer line ("Already have an account?") is a caller-owned ReactNode slot.

Props

PropTypeDefaultDescription
onSubmit({ email, password, confirmPassword }) => void | string | Promise<void | string>Fired with typed credentials after native validation + match + length checks pass. Return a string to set an inline error; throw / reject to fall back to the generic copy.
titleReactNode"Create your account"Heading rendered above the form. Pass null to hide.
descriptionReactNode"Enter your email below to create your account"Sub-heading rendered under the title. Pass null to hide.
submitLabelReactNode"Create account"Label on the submit button.
loadingLabelReactNode"Creating account…"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.
confirmPasswordLabelReactNode"Confirm password"Label on the confirm-password input.
passwordHintReactNode"Must be at least 8 characters."Helper text under the password row. Pass null to hide.
minPasswordLengthnumber8Minimum password length enforced before onSubmit fires.
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"Create account"Accessible name for the <form> element.
classNamestringMerged onto the rendered <form>.

Accessibility

  • The root is a <form aria-label="Create account"> so screen readers announce the form as a named landmark.
  • Inputs are wired with htmlFor / id via useId(), plus autoComplete="email" and autoComplete="new-password" so password managers offer to save the freshly-minted credentials.
  • 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, all three inputs flip aria-invalid="true" and reference the error region via aria-describedby. The password input also references the password-hint via aria-describedby so screen readers read the minimum-length rule on focus.
  • 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/signup-form.tsx). The source coupled the form to @convex-dev/auth/react's useAuthActions(), ran sign-up via signIn("password", formData) with a hardcoded flow="signUp" field, and rendered GitHub / Google OAuth buttons, an absolute cover image, a <Card> chrome, and a Terms / Privacy footer line. craft-bits strips the auth-provider dependency, drops the OAuth row + cover image + Card chrome (callers compose those externally), folds the password-match + min-length rules into the component, and turns the only mandatory behaviour into a generic onSubmit({ email, password, confirmPassword }) callback with an optional footerSlot plug.