Skip to content
Corsair UI
Browse the docs

BlocksNo. 136 of 145

Signup Form

A centred sign-up card with name, email and password fields, a required terms checkbox, optional sign-up providers and pending, success and error states.

Installation

pnpm dlx shadcn@latest add @corsair-ui/signup-form

No setup needed: the CLI finds @corsair-ui in the shadcn registry directory. The files land in your project as source: edit them like your own code. More in Installation.

Also adds
utilsbuttoncardcheckboxlabelfieldinputinput-grouppassword-inputseparator
npm
@radix-ui/react-checkbox@radix-ui/react-label@radix-ui/react-separator@radix-ui/react-slotclass-variance-authorityclsxlucide-reacttailwind-merge

Examples

Default

The demo does not send anything: it waits a moment, then shows the success message.

Open in a new tab

Longer password

minLength={12}, with a passwordHint label to match.

Open in a new tab

API

PropTypeDefault
title / description

The card header; the title is an h1.

ReactNode"Create an account" / example copy
terms

Label of the required terms checkbox; it can contain links.

ReactNodelinks to Terms and Privacy Policy
minLength

Shortest password accepted. Change labels.passwordHint to match.

number8
signinHref

Target of the sign-in link in the footer; null hides the line.

string | null"#sign-in"
providers

Outline buttons under an or continue with separator. Each has label, and optionally id, icon, href (a link) or onClick (a button).

SignupFormProvider[]—
labels

Overrides for any string; the rest keep their English defaults.

Partial<SignupFormLabels>—
onSubmit

Gets { name, email, password } instead of a native submit. Resolving resets the form and shows success; throwing keeps the values and shows the error.

(values: SignupFormValues) => void | Promise<void>—
formProps

Props for the form, such as action and method when there is no onSubmit.

ComponentProps<"form">—

Styling

Restyle it in your project: className on each part, the theme variables, and these attributes with arbitrary variants, e.g. className="[&_[data-slot=signup-form-card]]:text-primary".

  • The section and signup-form-form have data-status ("idle", "pending", "success" or "error") while onSubmit runs and after it settles.
  • The parts are signup-form-card, signup-form-password-hint, signup-form-terms, signup-form-submit, signup-form-providers and signup-form-signin.
data-slot
  • signup-form
  • signup-form-card
  • signup-form-container
  • signup-form-content
  • signup-form-description
  • signup-form-error
  • signup-form-footer
  • signup-form-form
  • signup-form-header
  • signup-form-password-hint
  • signup-form-provider
  • signup-form-providers
  • signup-form-separator
  • signup-form-signin
  • signup-form-submit
  • signup-form-success
  • signup-form-terms
  • signup-form-title
State
  • data-statuscomputed

Accessibility

  • The section is named by its h1. Every field is required with autoComplete ("name", "email", "new-password"), so the browser blocks a short password or an unchecked terms box before onSubmit runs.
  • The password hint is linked to its field with aria-describedby, and a button reveals the password, with aria-pressed for its state.
  • Success goes to a role="status" region and the error to a role="alert" region; both stay mounted so they are announced.

Compatibility

Tailwind CSS
3.4 and 4, both checked in CI
React
19
Rendering
Client component ("use client")

Source

1 file, installed as source you own
signup-form.tsxView on GitHub
"use client";

import { useId, useState, type ComponentProps, type ReactNode, type SubmitEvent } from "react";

import { cn } from "@/registry/default/lib/utils";
import { Button } from "@/registry/default/ui/button";
import { Card, CardContent, CardFooter, CardHeader } from "@/registry/default/ui/card";
import { Checkbox } from "@/registry/default/ui/checkbox";
import { Field, FieldDescription, FieldLabel } from "@/registry/default/ui/field";
import { Input } from "@/registry/default/ui/input";
import { PasswordInput } from "@/registry/default/ui/password-input";
import { Separator } from "@/registry/default/ui/separator";

/** What the form hands to `onSubmit`, read from its fields with FormData. */
interface SignupFormValues {
  name: string;
  email: string;
  password: string;
}

/** A third-party sign-up option, rendered as an outline button under the form. */
interface SignupFormProvider {
  /** React key; defaults to `label`. */
  id?: string;
  /** Visible text, such as "Continue with GitHub". */
  label: string;
  /** Decorative icon before the label; it is hidden from screen readers. */
  icon?: ReactNode;
  /** Renders a button that calls this. Ignored when `href` is set. */
  onClick?: () => void;
  /** Renders a link instead of a button. */
  href?: string;
}

/** Every string the form shows, including the button and status messages. */
interface SignupFormLabels {
  name: ReactNode;
  email: ReactNode;
  password: ReactNode;
  /** Hint under the password field, linked to it with `aria-describedby`. */
  passwordHint: ReactNode;
  /** Accessible name of the button that reveals the password. */
  showPassword: string;
  submit: ReactNode;
  /** Button text while `onSubmit` is running. */
  pending: ReactNode;
  /** Announced in the status region once `onSubmit` resolves. */
  success: ReactNode;
  /** Announced in the alert region when `onSubmit` throws. */
  error: ReactNode;
  /** Text in the separator above the providers. */
  or: ReactNode;
  /** Text before the sign-in link in the footer. */
  signinPrompt: ReactNode;
  signin: ReactNode;
}

type SignupFormStatus = "idle" | "pending" | "success" | "error";

const defaultLabels: SignupFormLabels = {
  name: "Name",
  email: "Email",
  password: "Password",
  passwordHint: "At least 8 characters",
  showPassword: "Show password",
  submit: "Create account",
  pending: "Creating account…",
  success: "Your account is ready.",
  error: "Could not create your account. Please try again.",
  or: "or continue with",
  signinPrompt: "Already have an account?",
  signin: "Sign in",
};

const linkClassName =
  "text-foreground focus-visible:ring-ring/50 rounded-md font-medium underline-offset-4 outline-none hover:underline focus-visible:ring-[3px]";

const defaultTerms = (
  <>
    I agree to the{" "}
    <a href="#terms" className={cn(linkClassName, "underline")}>
      Terms
    </a>{" "}
    and{" "}
    <a href="#privacy" className={cn(linkClassName, "underline")}>
      Privacy Policy
    </a>
  </>
);

interface SignupFormProps extends Omit<ComponentProps<"section">, "title" | "onSubmit"> {
  /** Card title, rendered as an `<h1>` since the form is usually the page's main content. */
  title?: ReactNode;
  description?: ReactNode;
  /** Label of the required terms checkbox; it can contain links. */
  terms?: ReactNode;
  /** Shortest password the field accepts (native `minLength`). */
  minLength?: number;
  /** Target of the "Sign in" link in the footer. Pass `null` to hide the footer line. */
  signinHref?: string | null;
  /** Third-party sign-up options shown under a separator. None by default. */
  providers?: SignupFormProvider[];
  /** Overrides for any of the form's strings; the rest keep their English defaults. */
  labels?: Partial<SignupFormLabels>;
  /**
   * Called with the field values instead of a native submit. While it runs the
   * button is disabled; resolving resets the form and shows `labels.success`,
   * throwing keeps the values and shows `labels.error`.
   */
  onSubmit?: (values: SignupFormValues) => void | Promise<void>;
  /** Props for the `<form>`, such as `action` and `method` for a native submit. */
  formProps?: ComponentProps<"form">;
}

/**
 * A sign-up card centred in a section: a title, a description, name, email
 * and password fields, a required terms checkbox, the submit button,
 * optional third-party providers under an "or continue with" separator and
 * an "Already have an account? Sign in" line. `<SignupForm />` renders a
 * complete example.
 *
 * Every field is `required` with `autoComplete` ("name", "email",
 * "new-password"); the password also has `minLength` (8 by default) and a
 * hint linked with `aria-describedby`, so the browser blocks a short
 * password or an unchecked terms box before `onSubmit` runs. The password
 * field has a button to reveal what was typed (`aria-pressed` tells its
 * state). The section is named by its `<h1>`.
 *
 * With `onSubmit`, the form does not navigate: the section and form get
 * `data-status="pending"`, then `"success"` or `"error"`. The success message
 * goes to a `role="status"` region and the error to a `role="alert"` region;
 * both stay mounted (empty when idle) so screen readers announce them.
 * Without `onSubmit` the form submits natively: pass `action` and `method`
 * through `formProps`.
 *
 * @example
 * <SignupForm
 *   minLength={12}
 *   labels={{ passwordHint: "At least 12 characters" }}
 *   terms={<>I accept the <a href="/terms">terms of service</a></>}
 *   onSubmit={async (values) => {
 *     await createAccount(values);
 *   }}
 * />
 */
function SignupForm({
  title = "Create an account",
  description = "Enter your details to get started.",
  terms = defaultTerms,
  minLength = 8,
  signinHref = "#sign-in",
  providers,
  labels: labelsProp,
  onSubmit,
  formProps,
  className,
  ...props
}: SignupFormProps) {
  const id = useId();
  const [status, setStatus] = useState<SignupFormStatus>("idle");
  const labels = { ...defaultLabels, ...labelsProp };
  const pending = status === "pending";

  async function handleSubmit(event: SubmitEvent<HTMLFormElement>) {
    formProps?.onSubmit?.(event);
    // No handler, or the consumer cancelled it: leave the native submit alone.
    if (!onSubmit || event.defaultPrevented) return;
    event.preventDefault();
    if (pending) return;

    // React clears currentTarget once the event is handled, so keep the form.
    const form = event.currentTarget;
    const data = new FormData(form);
    const values: SignupFormValues = {
      name: String(data.get("name") ?? ""),
      email: String(data.get("email") ?? ""),
      password: String(data.get("password") ?? ""),
    };

    setStatus("pending");
    try {
      await onSubmit(values);
      form.reset();
      setStatus("success");
    } catch {
      setStatus("error");
    }
  }

  return (
    <section
      data-slot="signup-form"
      data-status={status}
      aria-labelledby={`${id}-title`}
      className={cn("py-16 sm:py-24", className)}
      {...props}
    >
      <div
        data-slot="signup-form-container"
        className="mx-auto flex w-full max-w-6xl justify-center px-4 sm:px-6"
      >
        <Card data-slot="signup-form-card" className="w-full max-w-sm">
          <CardHeader data-slot="signup-form-header">
            <h1
              id={`${id}-title`}
              data-slot="signup-form-title"
              className="text-xl leading-tight font-semibold tracking-tight"
            >
              {title}
            </h1>
            {description ? (
              <p data-slot="signup-form-description" className="text-muted-foreground text-sm">
                {description}
              </p>
            ) : null}
          </CardHeader>
          <CardContent data-slot="signup-form-content" className="flex flex-col gap-6">
            <form
              data-slot="signup-form-form"
              data-status={status}
              {...formProps}
              className={cn("flex flex-col gap-6", formProps?.className)}
              onSubmit={handleSubmit}
            >
              <Field>
                <FieldLabel htmlFor={`${id}-name`}>{labels.name}</FieldLabel>
                <Input id={`${id}-name`} name="name" autoComplete="name" required />
              </Field>
              <Field>
                <FieldLabel htmlFor={`${id}-email`}>{labels.email}</FieldLabel>
                <Input id={`${id}-email`} name="email" type="email" autoComplete="email" required />
              </Field>
              <Field>
                <FieldLabel htmlFor={`${id}-password`}>{labels.password}</FieldLabel>
                <PasswordInput
                  id={`${id}-password`}
                  name="password"
                  autoComplete="new-password"
                  minLength={minLength}
                  toggleLabel={labels.showPassword}
                  aria-describedby={`${id}-password-hint`}
                  required
                />
                <FieldDescription id={`${id}-password-hint`} data-slot="signup-form-password-hint">
                  {labels.passwordHint}
                </FieldDescription>
              </Field>
              <Field orientation="horizontal" data-slot="signup-form-terms" className="items-start">
                <Checkbox id={`${id}-terms`} name="terms" required className="mt-0.5" />
                <FieldLabel htmlFor={`${id}-terms`} className="font-normal">
                  {terms}
                </FieldLabel>
              </Field>
              <div data-slot="signup-form-footer">
                <Button
                  type="submit"
                  data-slot="signup-form-submit"
                  loading={pending}
                  className="w-full"
                >
                  {pending ? labels.pending : labels.submit}
                </Button>
                <p
                  role="status"
                  data-slot="signup-form-success"
                  className="mt-4 text-sm empty:mt-0"
                >
                  {status === "success" ? labels.success : null}
                </p>
                <p
                  role="alert"
                  data-slot="signup-form-error"
                  className="text-destructive mt-4 text-sm empty:mt-0"
                >
                  {status === "error" ? labels.error : null}
                </p>
              </div>
            </form>
            {providers && providers.length > 0 ? (
              <div data-slot="signup-form-providers" className="flex flex-col gap-4">
                <div data-slot="signup-form-separator" className="flex items-center gap-3">
                  <Separator className="flex-1" />
                  <span className="text-muted-foreground text-xs">{labels.or}</span>
                  <Separator className="flex-1" />
                </div>
                <div className="grid gap-2">
                  {providers.map((provider) => {
                    const content = (
                      <>
                        {provider.icon ? (
                          <span aria-hidden="true" className="contents">
                            {provider.icon}
                          </span>
                        ) : null}
                        {provider.label}
                      </>
                    );
                    return provider.href != null ? (
                      <Button
                        key={provider.id ?? provider.label}
                        asChild
                        variant="outline"
                        data-slot="signup-form-provider"
                        className="w-full"
                      >
                        <a href={provider.href}>{content}</a>
                      </Button>
                    ) : (
                      <Button
                        key={provider.id ?? provider.label}
                        type="button"
                        variant="outline"
                        data-slot="signup-form-provider"
                        className="w-full"
                        onClick={provider.onClick}
                      >
                        {content}
                      </Button>
                    );
                  })}
                </div>
              </div>
            ) : null}
          </CardContent>
          {signinHref != null ? (
            <CardFooter data-slot="signup-form-signin" className="justify-center">
              <p className="text-muted-foreground text-center text-sm">
                {labels.signinPrompt}{" "}
                <a href={signinHref} className={linkClassName}>
                  {labels.signin}
                </a>
              </p>
            </CardFooter>
          ) : null}
        </Card>
      </div>
    </section>
  );
}

export {
  SignupForm,
  type SignupFormLabels,
  type SignupFormProps,
  type SignupFormProvider,
  type SignupFormStatus,
  type SignupFormValues,
};