# Sign Up Form

> Sign-up card with a GitHub button, an email field that validates and shakes, an animated password-strength meter, a stateful submit and a success state that morphs in with a drawn check.

- Collection: Blocks
- Tier: free
- Docs: https://geturui.com/docs/sign-up-form
- Install: `npx shadcn@latest add @geturui/sign-up-form`
- Dependencies: motion, lucide-react

## Usage

```tsx
import { SignUpForm } from "@/components/blocks/sign-up-form";

export default function SignUp() {
  return (
    <SignUpForm
      signInHref="/sign-in"
      onGithub={() => (window.location.href = "/api/auth/github")}
      onSubmit={async ({ email, password }) => {
        const res = await fetch("/api/sign-up", { method: "POST", body: JSON.stringify({ email, password }) });
        if (!res.ok) throw new Error("Sign up failed");
      }}
    />
  );
}
```

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| onSubmit | `(values: { email; password }) => Promise<void> | void` | — | Called with valid values. Throw or reject to stay on the form and shake the button. |
| onGithub | `() => void` | — | GitHub button handler. |
| showGithub | `boolean` | true | Show the GitHub button and divider. |
| minPasswordLength | `number` | 8 | Minimum length; also the first step of the strength meter. |
| title / description | `string` | — | Card heading copy. |
| submitLabel | `string` | "Create account" | Submit button label. |
| termsHref / privacyHref / signInHref | `string` | — | Links in the footer line. |
| successTitle | `string` | "Check your inbox" | Heading of the success state. |

## Accessibility

- Fields have visible labels; errors are linked with aria-describedby and set aria-invalid.
- The show-password control is a labelled toggle button with aria-pressed; the strength label is announced politely.
- Enter submits from either field; the success state is a status region and "Use a different email" returns focus to the email field.

## Source

### components/blocks/sign-up-form.tsx

```tsx
"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Eye, EyeOff, Lock, Mail } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button, StatefulButton } from "@/components/ui/button";
import { Input } from "@/components/ui/input";

export interface SignUpValues {
  email: string;
  password: string;
}

export interface SignUpFormProps {
  title?: string;
  description?: string;
  /** Called with valid values. Reject to keep the form open and shake the button. */
  onSubmit?: (values: SignUpValues) => Promise<void> | void;
  onGithub?: () => void;
  /** Show the "Continue with GitHub" button and divider. */
  showGithub?: boolean;
  githubLabel?: string;
  minPasswordLength?: number;
  submitLabel?: string;
  termsHref?: string;
  privacyHref?: string;
  /** Shows "Already have an account?" with this link. */
  signInHref?: string;
  successTitle?: string;
  className?: string;
}

const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
const LEVELS = [
  { label: "Too short", tone: "bg-danger", text: "text-danger" },
  { label: "Weak", tone: "bg-danger", text: "text-danger" },
  { label: "Fair", tone: "bg-warning", text: "text-warning" },
  { label: "Good", tone: "bg-info", text: "text-info" },
  { label: "Strong", tone: "bg-success", text: "text-success" },
] as const;

function scorePassword(pw: string, min: number) {
  if (pw.length < min) return 0;
  let s = 1;
  if (/[a-z]/.test(pw) && /[A-Z]/.test(pw)) s++;
  if (/\d/.test(pw)) s++;
  if (/[^A-Za-z0-9]/.test(pw)) s++;
  if (pw.length >= min + 6 && s < 4) s++;
  return s;
}

function GithubMark({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 24 24" aria-hidden className={className} fill="currentColor">
      <path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
    </svg>
  );
}

function StrengthMeter({ score, visible }: { score: number; visible: boolean }) {
  const level = LEVELS[score];
  return (
    <AnimatePresence initial={false}>
      {visible && (
        <motion.div
          initial={{ height: 0, opacity: 0 }}
          animate={{ height: "auto", opacity: 1 }}
          exit={{ height: 0, opacity: 0 }}
          transition={{ height: { type: "spring", stiffness: 320, damping: 32 }, opacity: { duration: 0.15 } }}
          className="overflow-hidden"
        >
          <div className="flex items-center gap-3 pt-2">
            <div className="grid flex-1 grid-cols-4 gap-1.5" aria-hidden>
              {[1, 2, 3, 4].map((seg) => (
                <span key={seg} className="h-1.5 overflow-hidden rounded-full bg-surface-sunken ring-1 ring-inset ring-border">
                  <motion.span
                    className={cn("block h-full origin-left rounded-full transition-colors duration-300", level.tone)}
                    initial={false}
                    animate={{ scaleX: score >= seg ? 1 : 0 }}
                    transition={{ type: "spring", stiffness: 380, damping: 30, delay: score >= seg ? (seg - 1) * 0.04 : 0 }}
                  />
                </span>
              ))}
            </div>
            <span className={cn("w-16 text-right text-xs font-medium", level.text)} aria-live="polite">
              {level.label}
            </span>
          </div>
        </motion.div>
      )}
    </AnimatePresence>
  );
}

/** Sign-up card: GitHub button, validated email that shakes on error, animated password-strength meter, stateful submit and a success state that morphs in. */
export function SignUpForm({
  title = "Create your GetUrUI account",
  description = "Free forever for the open library. Upgrade when you need Pro blocks.",
  onSubmit,
  onGithub,
  showGithub = true,
  githubLabel = "Continue with GitHub",
  minPasswordLength = 8,
  submitLabel = "Create account",
  termsHref = "#",
  privacyHref = "#",
  signInHref,
  successTitle = "Check your inbox",
  className,
}: SignUpFormProps) {
  const reduce = useReducedMotion() ?? false;
  const [email, setEmail] = React.useState("");
  const [password, setPassword] = React.useState("");
  const [reveal, setReveal] = React.useState(false);
  const [errors, setErrors] = React.useState<{ email?: string; password?: string }>({});
  const [done, setDone] = React.useState<string | null>(null);
  const timer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
  const emailRef = React.useRef<HTMLInputElement>(null);
  const score = scorePassword(password, minPasswordLength);

  React.useEffect(
    () => () => {
      if (timer.current) clearTimeout(timer.current);
    },
    [],
  );

  async function submit() {
    const next: typeof errors = {};
    if (!EMAIL_RE.test(email.trim())) next.email = email ? "Enter a valid email, like you@company.com" : "Email is required";
    if (password.length < minPasswordLength) next.password = `Use at least ${minPasswordLength} characters`;
    setErrors(next);
    if (next.email || next.password) throw new Error("invalid");
    if (onSubmit) await onSubmit({ email: email.trim(), password });
    else await new Promise((r) => setTimeout(r, 1100));
    const submitted = email.trim();
    timer.current = setTimeout(() => setDone(submitted), 650);
  }

  function reset() {
    setDone(null);
    setPassword("");
    setErrors({});
    timer.current = setTimeout(() => emailRef.current?.focus(), 50);
  }

  return (
    <motion.div
      layout
      transition={{ type: "spring", stiffness: 300, damping: 32 }}
      className={cn("mx-auto w-full max-w-[420px] overflow-hidden rounded-xl border border-border bg-surface p-6 shadow-lg sm:p-8", className)}
    >
      <AnimatePresence mode="popLayout" initial={false}>
        {done === null ? (
          <motion.div
            key="form"
            layout="position"
            initial={{ opacity: 0, filter: "blur(8px)", scale: 0.98 }}
            animate={{ opacity: 1, filter: "blur(0px)", scale: 1 }}
            exit={{ opacity: 0, filter: "blur(8px)", scale: 0.98 }}
            transition={{ duration: 0.25 }}
            className="grid gap-6"
          >
            <header className="grid gap-1.5">
              <h2 className="font-display text-2xl font-semibold tracking-[-0.5px] text-ink">{title}</h2>
              {description && <p className="text-sm leading-[22px] text-ink-muted">{description}</p>}
            </header>

            {showGithub ? (
              <>
                <Button variant="secondary" size="lg" block onClick={onGithub}>
                  <GithubMark className="size-[18px]" />
                  {githubLabel}
                </Button>
                <div className="flex items-center gap-3 text-xs font-medium uppercase tracking-[0.5px] text-ink-subtle">
                  <span className="h-px flex-1 bg-border" />
                  or
                  <span className="h-px flex-1 bg-border" />
                </div>
              </>
            ) : null}

            <form
              noValidate
              className="grid gap-4"
              onSubmit={(e) => {
                e.preventDefault();
              }}
            >
              <Input
                ref={emailRef}
                type="email"
                name="email"
                label="Work email"
                placeholder="you@company.com"
                autoComplete="email"
                icon={<Mail className="size-4" aria-hidden />}
                value={email}
                error={errors.email}
                onChange={(e) => {
                  setEmail(e.target.value);
                  if (errors.email) setErrors((x) => ({ ...x, email: undefined }));
                }}
                onBlur={() => {
                  if (email && !EMAIL_RE.test(email.trim())) setErrors((x) => ({ ...x, email: "Enter a valid email, like you@company.com" }));
                }}
              />
              <div className="grid">
                <Input
                  type={reveal ? "text" : "password"}
                  name="password"
                  label="Password"
                  placeholder={`At least ${minPasswordLength} characters`}
                  autoComplete="new-password"
                  icon={<Lock className="size-4" aria-hidden />}
                  value={password}
                  error={errors.password}
                  onChange={(e) => {
                    setPassword(e.target.value);
                    if (errors.password) setErrors((x) => ({ ...x, password: undefined }));
                  }}
                  trailing={
                    <button
                      type="button"
                      onClick={() => setReveal((r) => !r)}
                      aria-label={reveal ? "Hide password" : "Show password"}
                      aria-pressed={reveal}
                      className="-mr-1.5 grid size-7 place-items-center rounded-md text-ink-subtle outline-none transition-colors hover:bg-surface-hover hover:text-ink focus-visible:shadow-[var(--focus-ring)]"
                    >
                      {reveal ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
                    </button>
                  }
                />
                <StrengthMeter score={score} visible={password.length > 0} />
              </div>
              <StatefulButton type="submit" size="lg" block className="mt-1" onAction={submit} successLabel="Account created">
                {submitLabel}
              </StatefulButton>
            </form>

            <p className="text-center text-xs leading-5 text-ink-subtle">
              By continuing you agree to the{" "}
              <a href={termsHref} className="text-ink-muted underline underline-offset-2 hover:text-ink">
                Terms
              </a>{" "}
              and{" "}
              <a href={privacyHref} className="text-ink-muted underline underline-offset-2 hover:text-ink">
                Privacy policy
              </a>
              .
              {signInHref && (
                <>
                  <br />
                  Already have an account?{" "}
                  <a href={signInHref} className="font-medium text-volt-ink hover:underline">
                    Sign in
                  </a>
                </>
              )}
            </p>
          </motion.div>
        ) : (
          <motion.div
            key="done"
            layout="position"
            role="status"
            initial={{ opacity: 0, filter: "blur(8px)", scale: 0.96 }}
            animate={{ opacity: 1, filter: "blur(0px)", scale: 1 }}
            exit={{ opacity: 0, filter: "blur(8px)" }}
            transition={{ type: "spring", stiffness: 300, damping: 28 }}
            className="grid justify-items-center gap-4 py-6 text-center"
          >
            <motion.span
              className="grid size-16 place-items-center rounded-full bg-volt-soft text-volt-ink"
              initial={reduce ? false : { scale: 0.4 }}
              animate={{ scale: 1 }}
              transition={{ type: "spring", stiffness: 420, damping: 16, delay: 0.05 }}
            >
              <svg viewBox="0 0 24 24" className="size-8" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" aria-hidden>
                <motion.path
                  d="M5 12.5l4.5 4.5L19 7.5"
                  initial={reduce ? false : { pathLength: 0 }}
                  animate={{ pathLength: 1 }}
                  transition={{ duration: 0.45, ease: [0.22, 1, 0.36, 1], delay: 0.2 }}
                />
              </svg>
            </motion.span>
            <div className="grid gap-1.5">
              <h2 className="font-display text-2xl font-semibold tracking-[-0.5px] text-ink">{successTitle}</h2>
              <p className="text-sm leading-[22px] text-ink-muted">
                We sent a confirmation link to <span className="font-medium text-ink">{done}</span>. It expires in 24 hours.
              </p>
            </div>
            <Button variant="ghost" size="sm" onClick={reset}>
              Use a different email
            </Button>
          </motion.div>
        )}
      </AnimatePresence>
    </motion.div>
  );
}

```
