Signup Form
alphav0.2.0Email and password signup with optional profile step, OAuth row, password strength meter, magic-link variant, consent gate, and honeypot.
Context
Sibling of `pricing-table` in the CMS conversion-block batch (2 of 2). Newsletter + share are already covered by `newsletter-signup` and `share-bar`. Coexists with `properties-form` (entity-edit; different intent) and `json-form` (schema-driven generic; wrong shape for the specific UX of a signup surface — password meter, OAuth split, multi-step flow, honeypot anti-spam, mutual-exclusion-controlled success screen). The hand-roll on RHF v7 + zod v4 keeps the bundle small (~1,400 LOC; 4 shadcn primitives, no internal pro-ui deps). Two flow variants (`single-step` / `two-step`) × two password strategies (`password` / `magic-link`) × two densities (`compact` / `default`) × optional OAuth row × declarative optional-fields bag × pluggable strength calculator (`strengthCalculator?: (password) => 0|1|2|3|4` — the v0.1 seam for the v0.2 zxcvbn opt-in). Controlled-status escape hatch mirrors newsletter-signup's pattern with explicit mutual-exclusion: if `status` is passed, internal state becomes read-only. Honeypot field renders off-screen (`position: absolute; left: -9999px` — `display: none` is bot-detectable, so the off-screen pattern is load-bearing) and exposes `isHoneypotTripped: boolean` on the payload for the consumer to flag spam upstream. Object-shape callbacks throughout (F-cross-12).
Installation
pnpm dlx shadcn@latest init"registries": {
"@ilinxa": "https://ui.ilinxa.com/r/{name}.json"
}pnpm dlx shadcn@latest add @ilinxa/signup-formAdd -fixtures for dummy data:
pnpm dlx shadcn@latest add @ilinxa/signup-form-fixturesPreview
Default — single-step
Email + password + consent gate. The simplest shape.
Create your account
Start free, upgrade when you grow.
Submit any form to see the discriminated payload here.
Demo source
Usage
When to use
Drop <SignupForm> when you need a production-grade sign-up surface. The component owns: email + password + ToS-consent + (optional) OAuth row + (optional) multi-step flow + password strength + (optional) magic-link mode + off-screen honeypot anti-spam + an accessible success swap. It does NOT own the backend call — your onSubmit handler wires Supabase / NextAuth / Clerk / raw fetch / whatever you use.
Quick start
"use client";
import { SignupForm } from "@/components/signup-form";
export function SignUp() {
return (
<SignupForm
heading="Create your account"
consent={{
required: true,
label: "I agree to the Terms and Privacy Policy",
href: "/terms",
}}
onSubmit={async (payload) => {
if (payload.isHoneypotTripped) {
// Spam bot filled the honeypot — silently flag upstream
analytics.track("signup_honeypot_tripped");
return;
}
await api.signUp(payload.values);
}}
signInHref="/sign-in"
/>
);
}Two-step flow + discriminated payload
Set flow="two-step" to render email/password in step 1 and optional profile fields in step 2. The submit payload is a discriminated envelope — consumers MUST switch on stepCompletedbecause the "Skip for now" button submits with step-1 values only:
<SignupForm
flow="two-step"
fields={{
firstName: { required: true },
lastName: true,
company: true,
}}
consent={{ required: true, label: <>I agree to the <Link href="/terms">Terms</Link></> }}
onSubmit={async (payload) => {
switch (payload.stepCompleted) {
case "single":
case "step2":
// Full payload available — profile fields populated
await api.signUp(payload.values);
return;
case "step1":
// User clicked "Skip for now" — only step-1 fields present
await api.signUp({
email: payload.values.email,
password: payload.values.password,
});
return;
}
}}
/>Narrowing optional profile fields as string | undefined was rejected because it silently lets naïve destructuring (const { firstName } = payload.values) send undefined values to backend APIs. The envelope forces the discriminant. Set skippableStepTwo={false}to remove the "Skip for now" button entirely — then stepCompleted will always be "step2" for two-step flows.
OAuth row + oauthIcons slot
Provider buttons render above the email field — vertical stack on mobile, horizontal flex on sm:and up. Default is text-only ("Continue with Google"); wire icons via the oauthIcons slot:
import { GitBranch, Mail } from "lucide-react";
// Note: lucide-react v1+ dropped branded icons (Google / GitHub / Apple)
// to dodge licensing. Use generic stand-ins or your own brand-compliant
// SVGs — the slot is consumer-driven.
<SignupForm
oauthProviders={["google", "github"]}
oauthIcons={{
google: <Mail className="size-4" />, // generic; swap for your brand SVG
github: <GitBranch className="size-4" />, // generic; swap for your brand SVG
}}
onOAuthClick={({ provider }) => {
// Drive your OAuth redirect / SDK call here.
// The component does NOT handle the handshake — it just fires the event.
void signInWithProvider(provider);
}}
consent={{ required: true, label: "I agree to the Terms" }}
onSubmit={...}
/>Branded Google / Apple / Microsoft icons are NOT bundled (licensing + chunk-size). The slot is consumer-driven; supply your own SVG and you control attribution.
Magic-link strategy
Set passwordStrategy="magic-link" to drop the password input entirely. The form becomes email + consent + (optional) OAuth, and your onSubmit handler emails a one-time link to the supplied address:
<SignupForm
passwordStrategy="magic-link"
submitButton={{ label: "Send me a link" }}
consent={{ required: true, label: "I agree to the Terms" }}
onSubmit={async (payload) => {
await api.sendMagicLink(payload.values.email);
}}
/>Password policy + strength calculator
Configure validators via passwordPolicy:
<SignupForm
passwordPolicy={{
minLength: 12,
requireUppercase: true,
requireNumber: true,
requireSymbol: false,
showStrengthMeter: true, // default
}}
// ... rest
/>The built-in strength meter scores from 0 (empty/unrated) to 4 (excellent), using(length, character-class-count) — pure-client, no peer deps. For corporate password policies or zxcvbn-style dictionary checks, plug your own calculator via the strengthCalculator prop:
import zxcvbn from "zxcvbn"; // your peer dep
<SignupForm
strengthCalculator={(password) => {
if (!password) return 0;
const { score } = zxcvbn(password); // returns 0-4
return score as 0 | 1 | 2 | 3 | 4;
}}
// ...
/>Controlled status (mutual-exclusion contract)
By default the component owns its idle → submitting → (success | error) lifecycle. To control it externally, pass status + onStatusChange. Mutual exclusion is explicit:
- If
statusis provided, internal state becomes read-only — the component readsstatusas source of truth and never self-transitions.onStatusChangefires when the component computes a transition for observers (you can reflect it back into yourstatusstate or ignore it). - If
statusis omitted, internal state owns transitions andonStatusChangefires on each one. - Mixing the two — passing
statusAND expecting the component to internally transition to"success"— is a contract violation. Pick one mode and stick with it.
const [status, setStatus] = useState<SignupFormStatus>("idle");
const [serverError, setServerError] = useState<string | undefined>();
<SignupForm
status={status}
onStatusChange={(next) => {
/* observe-only — we own the actual transitions below */
}}
errorMessage={serverError}
onSubmit={async (payload) => {
setStatus("submitting");
setServerError(undefined);
try {
await api.signUp(payload.values);
setStatus("success");
} catch (e) {
setStatus("error");
setServerError(e instanceof Error ? e.message : "Something went wrong");
}
}}
// ...
/>Honeypot anti-spam (why off-screen, not display: none)
Every flow includes a hidden <input name="website"> field that real users cannot tab into or see. Spam bots tuned to fill url / website / homepage fields will fill it; the submit payload exposes the trip via isHoneypotTripped: boolean.
The field uses CSS off-screen positioning (`position: absolute; left: -9999px`), NOT `display: none`. Serious form-fill bots detect `display: none` and skip those fields, defeating the trap. The off-screen pattern keeps the field rendered (and fillable) while invisible to real users.
The component does NOT auto-reject tripped submissions — it surfaces the flag and lets you decide. Common consumer patterns: silently return success to the bot (preferred — avoids tipping them off) + fire an analytics event, OR reject with a generic error message:
onSubmit={async (payload) => {
if (payload.isHoneypotTripped) {
analytics.track("signup_honeypot_tripped");
return; // silently succeed — don't tell the bot it failed
}
await api.signUp(payload.values);
}}Accessibility
<section aria-labelledby={headingId}>wraps the form; heading semantic level configurable viaheadingAs(defaulth2).- Each input:
<label htmlFor>→<input id>binding;aria-describedbypoints to the inline error region;aria-invalidflips on validation failure. - Inline errors:
role="alert"only when populated(so screen readers don't announce empty regions). - Step indicator:
role="status" aria-live="polite"— announces "Step 2 of 2" on transition. - Success screen:
role="status" aria-live="polite"— announces the success message on appear. - OAuth divider:
role="separator" aria-orientation="horizontal"with sr-only "or" label. - Submit button:
aria-busy+disabledduringsubmittingstatus. - Password show/hide toggle:
aria-pressed+aria-labelswap between "Show password" and "Hide password". - Honeypot:
aria-hidden="true"+tabIndex={-1}keep it off the AT tree and the keyboard tab order. - Step transition: 150ms CSS opacity fade —
prefers-reduced-motion: reducecollapses to a 0ms hard-swap so a11y tooling isn't disorientated.
FAQ
- Why isn't the submit button disabled when the form is invalid?
- That's an a11y anti-pattern — users get no feedback on what's wrong. The button stays enabled; submit triggers validation and surfaces inline errors. Use the controlled
statusescape hatch if you want explicit lock-out behavior. - Why a discriminated payload instead of optional profile fields?
- A flat
valuesshape withfirstName?: stringreads naturally but silently letsconst { firstName } = payload.valuessendundefinedto backend APIs when the user skipped step 2. The discriminated envelope (stepCompleted: "single" | "step1" | "step2") forces aswitchon the discriminant. - Does this depend on
@ilinxa/json-form? - No. Hand-rolled on RHF v7 + zod v4 directly. Signup is a specific named surface with strong UX defaults that don't map cleanly onto json-form's schema-driven primitives. Pulling json-form's ~12 shadcn primitives + bundle for one form is overkill. The two components coexist —
@ilinxa/json-formremains the right substrate for backend-driven / CMS-driven forms. - Why no built-in CAPTCHA?
- The off-screen honeypot is the v0.1 anti-spam. CAPTCHA / hCaptcha / Turnstile need a slot + a script-load contract that's out of scope for v0.1; planned for v0.2 if a real consumer asks. If your honeypot false-negative rate ever becomes a problem, add a CAPTCHA on top via your own slot rendered alongside this component.
- How do I surface server errors (e.g., "email already taken")?
- Pass the message via the controlled
errorMessageprop; it renders arole="alert"banner above the form. Pair with controlledstatusif you want to keep the form mounted inerrorstate instead of swapping to the success screen on resolve.
Features
- Two flow variants: `single-step` (one screen) and `two-step` (email/password/consent → optional profile fields with progress indicator + Skip-for-now button)
- Two password strategies: `password` (full validators + strength meter) and `magic-link` (email-only — drops password input entirely)
- Declarative optional fields — `fields?: { firstName, lastName, displayName, phone, company }`, each `boolean | { required: boolean }`
- Pluggable password-strength calculator (`strengthCalculator?: (password) => 0 | 1 | 2 | 3 | 4`) — built-in length+char-class heuristic; v0.2 zxcvbn opt-in lands against the same seam without a breaking change
- Discriminated submit-payload envelope (`stepCompleted: 'single' | 'step1' | 'step2'`) — forces consumers to switch on the completion discriminant; prevents naïve destructuring from sending `undefined` profile fields to backend APIs
- ToS-consent gate with ReactNode `label` for inline `<Link>` composition + `string + href?` convenience overload
- OAuth row above the email field (mobile stacked, desktop flex) — text-only buttons by default + `oauthIcons?: Partial<Record<OAuthProvider, ReactNode>>` slot for consumer branded SVGs (Google/Apple/Microsoft licensing untouched)
- Off-screen honeypot anti-spam (`position: absolute; left: -9999px`, NOT `display: none` which is bot-detectable) — `isHoneypotTripped: boolean` flag on the payload
- Internal `idle | submitting | success | error` state machine with controlled `status` / `onStatusChange` escape hatch + explicit mutual-exclusion contract (controlled mode is read-only for internal state)
- i18n labels bag covering every visible string (field labels, error messages, button text, strength-meter levels, divider text, success copy, step indicator template, password-toggle aria labels)
- 150ms CSS-only step transition; `prefers-reduced-motion: reduce` collapses to 0ms hard-swap (no Framer Motion peer)
- ARIA wiring — `aria-describedby` for inline errors, `role='alert'` only when populated, `role='status' aria-live='polite'` on step indicator + success screen, `role='separator'` on OAuth divider, `aria-busy` + `disabled` on submit during `submitting`
- Object-shape callbacks throughout (F-cross-12)
- Hand-rolled on react-hook-form v7 + @hookform/resolvers/zod + zod v4 — no @ilinxa/json-form dep