# 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.

A shadcn/ui block from the [@geturui](https://geturui.com/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/geturui/sign-up-form).

- Page, with a live preview: https://shadcn.exchange/r/geturui/sign-up-form
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/geturui/sign-up-form-light-440a995f7e66a4d2.png
- Registry item: https://geturui.com/r/sign-up-form.json
- Categories: blocks, sign up, auth, form, validation, password strength, register
- Listed: 2026-10-23, source last changed 2026-10-23

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/geturui/sign-up-form.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `lucide-react`, `motion`
- Registry items: `@geturui/button`, `@geturui/input`

## Files

### `registry/geturui/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>
  );
}
```

### `LICENSE-geturui.md`

```md
MIT License

Copyright (c) 2026 geturui.com

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
