Skip to content
goodtabs UI
BrowseBrowse components

Sign in form

Email and password sign in with a pending state, an or divider and provider buttons.

npx shadcn@latest add https://ui.goodtabs.co/r/sign-in-form.json

With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/sign-in-form

Sign in

New here? Create an account

Forgot?

Email, password, a full width submit and the GitHub button under the divider.

Without providers

Welcome back

New here? Create an account

Forgot?

providers null removes the divider and the provider row. Custom title.

Pending, then an error

Sign in

New here? Create an account

Forgot?

onSubmit waits, then throws, so the error line shows under the password.

Usage

import { SignInForm, SignInGitHubButton } from "@/components/sign-in-form"

<SignInForm
  signUpHref="/sign-up"
  forgotHref="/forgot"
  onSubmit={async ({ email, password }) => { await signIn(email, password) }}
  providers={<SignInGitHubButton onClick={() => signInWith("github")} />}
/>

Props

PropTypeDefaultDescription
onSubmit({ email, password }) => void | Promise<void>Called with the values. A promise shows a pending state; a throw shows an error.
titlestring"Sign in"Heading and submit label.
providersReactNode<SignInGitHubButton />Provider buttons under the divider. null hides the divider too.
signUpHrefstring"#"Where Create an account goes.
forgotHrefstring"#"Where Forgot? goes.

Source

sign-in-form/sign-in-form.tsx
"use client";

import * as React from "react";
import { Loader2Icon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";

type SignInFormProps = Omit<React.ComponentProps<"form">, "onSubmit" | "title"> & {
  /** Called with the values. Return a promise to show a pending state until it settles. */
  onSubmit?: (values: { email: string; password: string }) => void | Promise<void>;
  /** Heading. */
  title?: string;
  /** Provider buttons under the divider. Defaults to a GitHub button; pass null to hide the divider too. */
  providers?: React.ReactNode;
  /** Where "Create an account" goes. */
  signUpHref?: string;
  /** Where "Forgot?" goes. */
  forgotHref?: string;
};

/**
 * Email and password sign in with a primary submit, an "or" divider and provider buttons.
 * Pending locks the fields and spins the button; a thrown error shows under the fields.
 */
export function SignInForm({
  onSubmit,
  title = "Sign in",
  providers,
  signUpHref = "#",
  forgotHref = "#",
  className,
  ...props
}: SignInFormProps) {
  const id = React.useId();
  const [pending, setPending] = React.useState(false);
  const [error, setError] = React.useState<string | null>(null);

  const submit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const data = new FormData(e.currentTarget);
    const email = String(data.get("email") ?? "").trim();
    const password = String(data.get("password") ?? "");
    setError(null);
    try {
      setPending(true);
      await onSubmit?.({ email, password });
    } catch {
      setError("That email and password do not match");
    } finally {
      setPending(false);
    }
  };

  const providerSlot = providers === undefined ? <SignInGitHubButton /> : providers;

  return (
    <form
      onSubmit={submit}
      data-slot="sign-in-form"
      aria-busy={pending || undefined}
      className={cn("flex w-full max-w-sm flex-col gap-5", className)}
      {...props}
    >
      <div className="flex flex-col gap-1">
        <h2 className="m-0 text-xl leading-7 font-medium tracking-tight text-foreground">{title}</h2>
        <p className="m-0 text-sm text-muted-foreground">
          New here?{" "}
          <a
            href={signUpHref}
            className="rounded-sm text-foreground underline decoration-muted-foreground/50 underline-offset-4 transition-colors duration-100 ease-out outline-none hover:decoration-foreground focus-visible:ring-2 focus-visible:ring-ring/50"
          >
            Create an account
          </a>
        </p>
      </div>

      <fieldset disabled={pending} className="m-0 flex min-w-0 flex-col gap-4 border-0 p-0">
        <div className="flex flex-col gap-2">
          <Label htmlFor={`${id}-email`}>Email</Label>
          <Input id={`${id}-email`} name="email" type="email" autoComplete="email" required />
        </div>
        <div className="flex flex-col gap-2">
          <div className="flex items-center justify-between">
            <Label htmlFor={`${id}-password`}>Password</Label>
            <a
              href={forgotHref}
              className="rounded-sm text-xs text-muted-foreground transition-colors duration-100 ease-out outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50"
            >
              Forgot?
            </a>
          </div>
          <Input
            id={`${id}-password`}
            name="password"
            type="password"
            autoComplete="current-password"
            required
            aria-invalid={error ? true : undefined}
            aria-describedby={error ? `${id}-error` : undefined}
          />
        </div>
        <p id={`${id}-error`} aria-live="polite" className={cn("m-0 text-xs text-destructive", !error && "sr-only")}>
          {error ?? ""}
        </p>
        <Button type="submit" className="w-full">
          {pending && (
            <Loader2Icon
              data-icon="inline-start"
              className="animate-spin motion-reduce:animate-none"
              aria-hidden="true"
            />
          )}
          {title}
        </Button>
      </fieldset>

      {providerSlot && (
        <>
          <div className="flex items-center gap-3 text-xs text-muted-foreground" aria-hidden="true">
            <span className="h-px flex-1 bg-border" />
            or
            <span className="h-px flex-1 bg-border" />
          </div>
          <div className="flex flex-col gap-2">{providerSlot}</div>
        </>
      )}
    </form>
  );
}

/** The default provider: an outline button with the GitHub mark. Pass onClick or asChild with a link. */
export function SignInGitHubButton({
  className,
  children = "Continue with GitHub",
  ...props
}: React.ComponentProps<typeof Button>) {
  return (
    <Button type="button" variant="outline" className={cn("w-full", className)} {...props}>
      <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" data-icon="inline-start">
        <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>
      {children}
    </Button>
  );
}

For AI agents

Hand your agent this page, the markdown at /components/sign-in-form/prompt.md, or the Copy AI prompt button above.

When to use

  • The sign in page of an app with email and password
  • A sign in dialog or sheet, since it is a form with no page chrome
  • Apps that also offer one or two providers like GitHub or Google

When not to use

  • Sign up, which needs more fields and consent: build on form-section
  • Magic link or passwordless flows, which have no password field
  • Apps with many providers; a list of links reads better than a stack of buttons

Agent notes

  • onSubmit is a function, so the page that renders SignInForm must be a client component ("use client")
  • If onSubmit throws, a generic error shows under the password and the fields unlock; map your own errors there
  • The default GitHub button has no handler; pass providers with your own SignInGitHubButton or links
  • The form is max-w-sm and centers nothing; place it in your page layout