Skip to content
ilinxa/pro-ui

Signup Form

alphav0.2.0

Email and password signup with optional profile step, OAuth row, password strength meter, magic-link variant, consent gate, and honeypot.

Category: FormsUpdated: 2026-08-11Created: 2026-05-22Author: ilinxa

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

Initialize shadcn (once per project)Seeds lib/utils.ts and components.json. Skip if you've already used any shadcn component.
pnpm dlx shadcn@latest init
Register the @ilinxa namespace (once per project)Add to your components.json. Merge with existing config.
"registries": {
  "@ilinxa": "https://ui.ilinxa.com/r/{name}.json"
}
Install the component
pnpm dlx shadcn@latest add @ilinxa/signup-form

Add -fixtures for dummy data:

pnpm dlx shadcn@latest add @ilinxa/signup-form-fixtures

Preview

Default — single-step

Email + password + consent gate. The simplest shape.

Create your account

Start free, upgrade when you grow.

You must accept the terms to continue

Already have an account? Sign in

Submit any form to see the discriminated payload here.

Demo source

demo.tsxtsx

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 status is provided, internal state becomes read-only — the component reads status as source of truth and never self-transitions. onStatusChange fires when the component computes a transition for observers (you can reflect it back into your status state or ignore it).
  • If status is omitted, internal state owns transitions and onStatusChange fires on each one.
  • Mixing the two — passing status AND 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 via headingAs (default h2).
  • Each input: <label htmlFor> <input id> binding; aria-describedby points to the inline error region; aria-invalid flips 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 + disabled during submitting status.
  • 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 status escape hatch if you want explicit lock-out behavior.
Why a discriminated payload instead of optional profile fields?
A flat values shape with firstName?: string reads naturally but silently lets const { firstName } = payload.values send undefined to backend APIs when the user skipped step 2. The discriminated envelope (stepCompleted: "single" | "step1" | "step2") forces a switch on 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-form remains 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 errorMessage prop; it renders a role="alert" banner above the form. Pair with controlled status if you want to keep the form mounted in error state 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

Tags

signup-formformsignupauthoauthmagic-linkrhfzodhoneypottwo-step

Dependencies

shadcn primitives: input, label, button, checkbox
npm peer deps: react-hook-form@^7.75.0, @hookform/resolvers@^5.2.2, zod@^4.4.3, lucide-react@^1.11.0