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
Email, password, a full width submit and the GitHub button under the divider.
Without providers
providers null removes the divider and the provider row. Custom title.
Pending, then an error
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
| Prop | Type | Default | Description |
|---|---|---|---|
| onSubmit | ({ email, password }) => void | Promise<void> | Called with the values. A promise shows a pending state; a throw shows an error. | |
| title | string | "Sign in" | Heading and submit label. |
| providers | ReactNode | <SignInGitHubButton /> | Provider buttons under the divider. null hides the divider too. |
| signUpHref | string | "#" | Where Create an account goes. |
| forgotHref | string | "#" | 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