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.
- Try
[email protected]to see the inline error path.
Installation
npx shadcn@latest add https://craftbits.dev/r/signup-form.jsonUsage
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
- Provider-agnostic. The component never imports an auth SDK. It hands the caller
{ email, password, confirmPassword }and reacts to the return value:voidfor success, a string for an inline error, a thrown error for the generic could-not-create-account fallback. All transport (Convex action, RESTPOST, OAuth pre-flight) lives in caller code. - Two layers of validation, in order. The browser blocks the submit on the
required/type="email"rules; then the component checkspassword === confirmPasswordandpassword.length >= minPasswordLengthbeforeonSubmitfires. The caller never sees malformed credentials. - 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. - 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. - Slots, not hardcoded links. The footer line ("Already have an account?") is a caller-owned
ReactNodeslot.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
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. |
title | ReactNode | "Create your account" | Heading rendered above the form. Pass null to hide. |
description | ReactNode | "Enter your email below to create your account" | Sub-heading rendered under the title. Pass null to hide. |
submitLabel | ReactNode | "Create account" | Label on the submit button. |
loadingLabel | ReactNode | "Creating account…" | 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. |
confirmPasswordLabel | ReactNode | "Confirm password" | Label on the confirm-password input. |
passwordHint | ReactNode | "Must be at least 8 characters." | Helper text under the password row. Pass null to hide. |
minPasswordLength | number | 8 | Minimum password length enforced before onSubmit fires. |
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 | "Create account" | Accessible name for the <form> element. |
className | string | — | Merged 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/idviauseId(), plusautoComplete="email"andautoComplete="new-password"so password managers offer to save the freshly-minted credentials. - 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, all three inputs flip
aria-invalid="true"and reference the error region viaaria-describedby. The password input also references the password-hint viaaria-describedbyso screen readers read the minimum-length rule on focus. - 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/signup-form.tsx). The source coupled the form to@convex-dev/auth/react'suseAuthActions(), ran sign-up viasignIn("password", formData)with a hardcodedflow="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 genericonSubmit({ email, password, confirmPassword })callback with an optionalfooterSlotplug.