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 initpnpm dlx shadcn@latest add @ilinxa/signup-formAdd -fixtures for dummy data:
pnpm dlx shadcn@latest add @ilinxa/signup-form-fixturesCLI can't resolve @ilinxa? The namespace is listed in the official shadcn registry directory, so current CLIs need no configuration. If yours can't resolve it (older or pinned versions, self-hosted mirrors), register it manually in components.json:
"registries": {
"@ilinxa": "https://ui.ilinxa.com/r/{name}.json"
}Preview
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
"use client"; import { useCallback, useState } from "react";import { GitBranch, Mail } from "lucide-react";import { Button } from "@/components/ui/button";import { Tabs, TabsContent, TabsTrigger } from "@/components/ui/tabs";import { SwipeTabsList } from "@/components/site/swipe-tabs-list";import { SignupForm } from "./signup-form";import { controlledSignupProps, defaultSignupProps, denseSignupProps, magicLinkSignupProps, oauthSignupProps, twoStepSignupProps,} from "./dummy-data";import type { SignupFormStatus, SignupSubmitPayload,} from "./types"; function Section({ title, caption, children,}: { title: string; caption?: string; children: React.ReactNode;}) { return ( <section className="mx-auto flex w-full max-w-md flex-col gap-3 rounded-lg border border-border bg-card p-6"> <div className="flex flex-col gap-1"> <h3 className="text-sm font-medium text-foreground">{title}</h3> {caption ? ( <p className="text-xs text-muted-foreground">{caption}</p> ) : null} </div> {children} </section> );} function LastPayloadPanel({ payload,}: { payload: SignupSubmitPayload | null;}) { if (!payload) { return ( <p className="text-xs italic text-muted-foreground"> Submit any form to see the discriminated payload here. </p> ); } return ( <pre className="overflow-x-auto rounded-md border border-border bg-muted/40 p-3 font-mono text-[11px] leading-relaxed"> {JSON.stringify(payload, null, 2)} </pre> );} // ─── Tab 1: Default ────────────────────────────────────────────────────────── function DefaultTab() { const [last, setLast] = useState<SignupSubmitPayload | null>(null); return ( <Section title="Default — single-step" caption="Email + password + consent gate. The simplest shape." > <SignupForm {...defaultSignupProps} onSubmit={(payload) => setLast(payload)} /> <LastPayloadPanel payload={last} /> </Section> );} // ─── Tab 2: OAuth ──────────────────────────────────────────────────────────── function OauthTab() { const [last, setLast] = useState<SignupSubmitPayload | null>(null); return ( <Section title="OAuth row + oauthIcons slot" caption="The fixture leaves icons empty (text-only fallback). Here we wire lucide-react icons via the slot — Mail as a Google stand-in (no Google brand asset shipped) and Github for the github provider." > <SignupForm {...oauthSignupProps} oauthIcons={{ // lucide-react v1.x dropped branded icons (Google / GitHub / // Apple) to dodge licensing — these are generic stand-ins. // Consumers swap in their own brand-compliant SVGs. google: <Mail className="size-4" aria-hidden="true" />, github: <GitBranch className="size-4" aria-hidden="true" />, }} onOAuthClick={({ provider }) => console.log(`[signup-form demo] OAuth click:`, provider) } onSubmit={(payload) => setLast(payload)} /> <LastPayloadPanel payload={last} /> </Section> );} // ─── Tab 3: Two-step ───────────────────────────────────────────────────────── function TwoStepTab() { const [last, setLast] = useState<SignupSubmitPayload | null>(null); return ( <Section title="Two-step flow with skip" caption="Step 1 = email/password/consent. Step 2 = optional profile (firstName required, lastName + company optional). Skip-for-now button submits with `stepCompleted: 'step1'` so consumers can switch on the discriminant." > <SignupForm {...twoStepSignupProps} onSubmit={(payload) => setLast(payload)} /> <LastPayloadPanel payload={last} /> </Section> );} // ─── Tab 4: Magic-link ─────────────────────────────────────────────────────── function MagicLinkTab() { const [last, setLast] = useState<SignupSubmitPayload | null>(null); return ( <Section title="Magic-link strategy" caption="Drops the password input entirely; the form is email + consent + (optional) OAuth. Useful for low-friction sign-ups." > <SignupForm {...magicLinkSignupProps} onSubmit={(payload) => setLast(payload)} /> <LastPayloadPanel payload={last} /> </Section> );} // ─── Tab 5: Dense ──────────────────────────────────────────────────────────── function DenseTab() { const [last, setLast] = useState<SignupSubmitPayload | null>(null); return ( <Section title="Compact density" caption="Narrower max-width, tighter vertical rhythm. For sidebars, modals, or dense onboarding flows." > <SignupForm {...denseSignupProps} onSubmit={(payload) => setLast(payload)} /> <LastPayloadPanel payload={last} /> </Section> );} // ─── Tab 6: Controlled status ──────────────────────────────────────────────── function ControlledTab() { const [status, setStatus] = useState<SignupFormStatus>("idle"); const [last, setLast] = useState<SignupSubmitPayload | null>(null); // Mutual-exclusion contract: while `status` is provided, the component // is read-only on its own state. We own the transitions here. const handleSubmit = useCallback( async (payload: SignupSubmitPayload) => { setLast(payload); setStatus("submitting"); // Simulate a network round-trip. await new Promise((r) => setTimeout(r, 800)); // 70% success rate for the demo if (Math.random() > 0.3) { setStatus("success"); } else { setStatus("error"); } }, [], ); return ( <Section title="Controlled status (mutual-exclusion contract)" caption="`status` + `onStatusChange` controlled. The parent owns transitions; the component renders based on `status` and never self-transitions. 70% success rate so you can see both branches." > <div className="flex items-center gap-2"> <Button size="sm" variant="outline" onClick={() => setStatus("idle")} > Reset to idle </Button> <span className="text-xs text-muted-foreground"> status: <span className="font-mono">{status}</span> </span> </div> <SignupForm {...controlledSignupProps} status={status} onStatusChange={(next) => console.log(`[signup-form demo] status change requested:`, next) } errorMessage={ status === "error" ? "Demo error — your account already exists." : undefined } onSubmit={handleSubmit} /> <LastPayloadPanel payload={last} /> </Section> );} // ─── Tab shell ─────────────────────────────────────────────────────────────── export default function SignupFormDemo() { return ( <Tabs defaultValue="default" className="w-full"> <SwipeTabsList> <TabsTrigger value="default">Default</TabsTrigger> <TabsTrigger value="oauth">OAuth</TabsTrigger> <TabsTrigger value="two-step">Two-step</TabsTrigger> <TabsTrigger value="magic-link">Magic-link</TabsTrigger> <TabsTrigger value="dense">Dense</TabsTrigger> <TabsTrigger value="controlled">Controlled status</TabsTrigger> </SwipeTabsList> <TabsContent value="default" className="pt-3"> <DefaultTab /> </TabsContent> <TabsContent value="oauth" className="pt-3"> <OauthTab /> </TabsContent> <TabsContent value="two-step" className="pt-3"> <TwoStepTab /> </TabsContent> <TabsContent value="magic-link" className="pt-3"> <MagicLinkTab /> </TabsContent> <TabsContent value="dense" className="pt-3"> <DenseTab /> </TabsContent> <TabsContent value="controlled" className="pt-3"> <ControlledTab /> </TabsContent> </Tabs> );} 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