A sign-in panel that also creates accounts, with provider buttons, validation and progress states. The preview sends nothing anywhere.
Sign in to pick up where you left off.
New here?
pnpm dlx shadcn@latest add @latheui/sign-inFirst time? Run npx latheui init once to add the @latheui registry. The CLI then writes the source into your project and installs what it needs. Or add it by hand:
Install the npm dependencies.
pnpm add motionAdd the Lathe items this component imports.
lib/motion.tshooks/use-controllable-state.tsCopy the source into your project.
"use client"
import * as React from "react"
import { AnimatePresence, motion } from "motion/react"
import { cn } from "@/lib/utils"
import { useControllableState } from "@/hooks/use-controllable-state"
import { duration, ease, useMotionSafe } from "@/lib/motion"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { Field, Input } from "@/components/ui/input"
import { PasswordField } from "@/components/ui/password-field"
export type SignInValues = { email: string; password: string; remember: boolean }
export type CreateAccountValues = { name: string; email: string; password: string }
export type SignInMode = "sign-in" | "create"
export type SignInProvider = { id: string; label: string; icon?: React.ReactNode }
export type SignInProps = Omit<React.ComponentProps<"section">, "children" | "onSubmit"> & {
title?: string
description?: string
/** Heading and description while creating an account. */
createTitle?: string
createDescription?: string
/** Which form is showing. Leave it out to let the panel switch on its own. */
mode?: SignInMode
defaultMode?: SignInMode
onModeChange?: (mode: SignInMode) => void
/** Other ways in, such as "Continue with GitHub". Shown above the form in both modes. */
providers?: SignInProvider[]
/** Signs in. Return a promise to show progress; reject with an `Error` to show its message. */
onSubmit?: (values: SignInValues) => void | Promise<void>
/** Creates the account. Return a promise to show progress; reject with an `Error` to show its message. */
onCreateAccount?: (values: CreateAccountValues) => void | Promise<void>
/** Starts a provider's flow. Return a promise to show progress on that button. */
onProvider?: (id: string) => void | Promise<void>
onForgotPassword?: (email: string) => void
}
const looksLikeEmail = (value: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
const MIN_PASSWORD = 8
/**
* A sign-in panel that also creates accounts: providers, email and password with validation,
* progress and a place for errors. The email carries over between the two forms.
*/
function SignIn({
title = "Welcome back",
description = "Sign in to pick up where you left off.",
createTitle = "Create your account",
createDescription = "It takes less than a minute.",
mode: modeProp,
defaultMode = "sign-in",
onModeChange,
providers = [],
onSubmit,
onCreateAccount,
onProvider,
onForgotPassword,
className,
...props
}: SignInProps) {
const [mode, setMode] = useControllableState({ value: modeProp, defaultValue: defaultMode, onChange: onModeChange })
const creating = mode === "create"
const [name, setName] = React.useState("")
const [email, setEmail] = React.useState("")
const [password, setPassword] = React.useState("")
const [remember, setRemember] = React.useState(true)
// Field errors appear after the first attempt, then follow the fields as they are corrected.
const [attempted, setAttempted] = React.useState(false)
const [busy, setBusy] = React.useState<string | null>(null)
const [failure, setFailure] = React.useState("")
const titleId = React.useId()
const switched = React.useRef(false)
// The link that switched forms is gone, so focus moves to the new form's first field. The
// form mounts only after the old one has faded out, so this runs when the field appears.
const firstField = React.useCallback((node: HTMLInputElement | null) => {
if (!node || !switched.current) return
switched.current = false
node.focus()
}, [])
const { safe, reduced } = useMotionSafe()
const nameError = creating && !name.trim() ? "Enter your name." : ""
const emailError = !email ? "Enter your email." : !looksLikeEmail(email) ? "That does not look like an email address." : ""
const passwordError = !password
? creating
? "Choose a password."
: "Enter your password."
: creating && password.length < MIN_PASSWORD
? `Use at least ${MIN_PASSWORD} characters.`
: ""
function switchTo(next: SignInMode) {
switched.current = true
setAttempted(false)
setFailure("")
setPassword("")
setMode(next)
}
async function run(key: string, work: () => void | Promise<void>) {
setFailure("")
setBusy(key)
try {
await work()
} catch (error) {
setFailure(error instanceof Error && error.message ? error.message : "Something went wrong. Try again.")
} finally {
setBusy(null)
}
}
const swap = {
initial: reduced ? { opacity: 0 } : { opacity: 0, y: 6 },
animate: { opacity: 1, y: 0 },
exit: { opacity: 0 },
transition: safe({ duration: duration.base, ease: ease.out }),
}
return (
<section
data-slot="sign-in"
data-mode={mode}
aria-labelledby={titleId}
className={cn("flex w-full max-w-sm flex-col gap-5 rounded-2xl border border-border bg-card p-6 sm:p-8", className)}
{...props}
>
<AnimatePresence mode="wait" initial={false}>
<motion.header key={mode} {...swap} className="flex flex-col gap-1.5">
<h2 id={titleId} className="text-xl font-semibold tracking-tight">
{creating ? createTitle : title}
</h2>
<p className="text-sm leading-6 text-muted-foreground">{creating ? createDescription : description}</p>
</motion.header>
</AnimatePresence>
{providers.length > 0 && (
<>
<div className="flex flex-col gap-2">
{providers.map((provider) => (
<Button
key={provider.id}
variant="outline"
loading={busy === provider.id}
disabled={busy !== null && busy !== provider.id}
onClick={() => run(provider.id, () => onProvider?.(provider.id))}
>
{provider.icon}
{provider.label}
</Button>
))}
</div>
{/* Pulled in toward its neighbors: it joins the two ways in rather than separating sections. */}
<p aria-hidden="true" className="-my-1.5 flex items-center gap-3 text-xs text-muted-foreground before:h-px before:flex-1 before:bg-border after:h-px after:flex-1 after:bg-border">
or
</p>
</>
)}
<AnimatePresence mode="wait" initial={false}>
<motion.form
key={mode}
{...swap}
noValidate
aria-label={creating ? "Create account" : "Sign in"}
className="flex flex-col gap-4"
onSubmit={(event) => {
event.preventDefault()
setAttempted(true)
if (nameError || emailError || passwordError) return
void run("form", () =>
creating ? onCreateAccount?.({ name: name.trim(), email, password }) : onSubmit?.({ email, password, remember })
)
}}
>
{creating && (
<Field label="Name" error={attempted ? nameError : undefined}>
<Input
ref={firstField}
name="name"
autoComplete="name"
placeholder="Ada Lovelace"
value={name}
onChange={(event) => setName(event.target.value)}
/>
</Field>
)}
<Field label="Email" error={attempted ? emailError : undefined}>
<Input
ref={creating ? undefined : firstField}
type="email"
name="email"
autoComplete="email"
placeholder="you@example.com"
value={email}
onChange={(event) => setEmail(event.target.value)}
/>
</Field>
<div className="flex flex-col gap-1.5">
<PasswordField
label="Password"
name="password"
autoComplete={creating ? "new-password" : "current-password"}
// A new password shows how strong it is; signing in does not judge an existing one.
strength={creating}
value={password}
onChange={(event) => setPassword(event.target.value)}
aria-invalid={attempted && Boolean(passwordError)}
className={cn(attempted && passwordError && "border-destructive")}
/>
{attempted && passwordError && <p className="text-[13px] leading-5 text-destructive">{passwordError}</p>}
</div>
{!creating && (
<div className="flex items-center justify-between gap-3">
<Checkbox
label="Keep me signed in"
checked={remember}
onCheckedChange={(checked) => setRemember(checked === true)}
/>
<button
type="button"
onClick={() => onForgotPassword?.(email)}
className="cursor-pointer rounded-sm text-sm text-muted-foreground underline-offset-4 outline-none hover:text-foreground hover:underline focus-visible:ring-2 focus-visible:ring-ring"
>
Forgot password?
</button>
</div>
)}
<AnimatePresence initial={false}>
{failure && (
<motion.p
role="alert"
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={safe({ duration: duration.base, ease: ease.out })}
className="overflow-hidden text-[13px] leading-5 text-destructive"
>
{failure}
</motion.p>
)}
</AnimatePresence>
<Button type="submit" loading={busy === "form"} disabled={busy !== null && busy !== "form"}>
{creating ? "Create account" : "Sign in"}
</Button>
</motion.form>
</AnimatePresence>
<p className="text-center text-sm text-muted-foreground">
{creating ? "Already have an account?" : "New here?"}{" "}
<button
type="button"
disabled={busy !== null}
onClick={() => switchTo(creating ? "sign-in" : "create")}
className="cursor-pointer rounded-sm font-medium text-foreground underline-offset-4 outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
>
{creating ? "Sign in" : "Create an account"}
</button>
</p>
</section>
)
}
export { SignIn }Depends on
Also accepts native section attributes.
| Prop | Type | Default | Description |
|---|---|---|---|
| title | string | "Welcome back" | Heading and accessible name of the panel. |
| description | string | — | Line under the heading. |
| createTitle | string | "Create your account" | Heading while creating an account. |
| createDescription | string | — | Line under that heading. |
| mode | "sign-in" | "create" | — | Which form shows. Leave it out to let the link under the form switch it. |
| defaultMode | "sign-in" | "create" | "sign-in" | The form shown first when mode is left out. |
| onModeChange | (mode: SignInMode) => void | — | Called when the link under the form switches forms. Keep mode at "sign-in" and route here to send people to a separate sign-up page instead. |
| providers | SignInProvider[] | [] | Other ways in, each with id, label and optional icon. Shown above the form in both modes. |
| onSubmit | (values: SignInValues) => void | Promise<void> | — | Signs in with email, password and remember. Return a promise to show progress; reject with an Error to show its message. |
| onProvider | (id: string) => void | Promise<void> | — | Starts a provider's flow. Return a promise to show progress on that button. |
| onForgotPassword | (email: string) => void | — | Called with whatever is in the email field. |
| onCreateAccount | (values: CreateAccountValues) => void | Promise<void> | — | Creates the account with name, email and password. Return a promise to show progress; reject with an Error to show its message. |
<SignIn
title="Sign in to Northwind"
description="Use your work email."
className="max-w-md"
onForgotPassword={(email) => router.push(`/reset?email=${encodeURIComponent(email)}`)}
onSubmit={signIn}
onCreateAccount={createAccount}
/><SignIn
mode="sign-in"
onModeChange={(mode) => mode === "create" && router.push("/join")}
onSubmit={signIn}
/>