Authentication
Passwordless sign-in in two steps, a social account or an email address and then a six-digit code, with inline validation. The brand panel stays dark in both themes.
Add this example to a project that has prfct installed. The command writes the files below and installs the components they use.
$ pnpm dlx shadcn@latest add https://www.prfct.dev/r/authentication.json"use client"
import * as React from "react"
import { ArrowLeftIcon, MailCheckIcon, ZapIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Field,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
FieldSeparator,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import {
InputOTP,
InputOTPGroup,
InputOTPSeparator,
InputOTPSlot,
} from "@/components/ui/input-otp"
import { toast } from "@/components/ui/toast"
function GithubIcon(props: React.ComponentProps<"svg">) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" {...props}>
<path d="M12 .5C5.73.5.75 5.48.75 11.75c0 4.97 3.22 9.18 7.69 10.67.56.1.77-.24.77-.54v-1.9c-3.13.68-3.79-1.5-3.79-1.5-.51-1.3-1.25-1.65-1.25-1.65-1.02-.7.08-.69.08-.69 1.13.08 1.72 1.16 1.72 1.16 1 1.72 2.63 1.22 3.27.93.1-.73.39-1.22.71-1.5-2.5-.28-5.13-1.25-5.13-5.57 0-1.23.44-2.24 1.16-3.03-.12-.28-.5-1.43.11-2.98 0 0 .95-.3 3.1 1.16a10.8 10.8 0 0 1 5.64 0c2.15-1.46 3.1-1.16 3.1-1.16.61 1.55.23 2.7.11 2.98.72.79 1.16 1.8 1.16 3.03 0 4.33-2.64 5.28-5.15 5.56.4.35.76 1.03.76 2.08v3.08c0 .3.2.65.78.54a11.26 11.26 0 0 0 7.68-10.67C23.25 5.48 18.27.5 12 .5Z" />
</svg>
)
}
function GoogleIcon(props: React.ComponentProps<"svg">) {
return (
<svg viewBox="0 0 24 24" aria-hidden="true" {...props}>
<path
fill="#4285F4"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.27-4.74 3.27-8.1Z"
/>
<path
fill="#34A853"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84A11 11 0 0 0 12 23Z"
/>
<path
fill="#FBBC05"
d="M5.84 14.09A6.6 6.6 0 0 1 5.5 12c0-.73.13-1.43.34-2.09V7.07H2.18A11 11 0 0 0 1 12c0 1.78.43 3.45 1.18 4.93l3.66-2.84Z"
/>
<path
fill="#EA4335"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15A10.96 10.96 0 0 0 12 1 11 11 0 0 0 2.18 7.07l3.66 2.84C6.71 7.31 9.14 5.38 12 5.38Z"
/>
</svg>
)
}
const EMAIL = /^\S+@\S+\.\S+$/
export default function AuthenticationExample() {
const [step, setStep] = React.useState<"email" | "code">("email")
const [email, setEmail] = React.useState("")
const [code, setCode] = React.useState("")
const [error, setError] = React.useState<string | null>(null)
const [pending, setPending] = React.useState(false)
async function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
const invalid =
step === "email" && !EMAIL.test(email)
? "Enter an email address like ada@example.com."
: step === "code" && code.length < 6
? "Enter all six digits from the email."
: null
if (invalid) {
setError(invalid)
// Each step has one input: send people back to it.
event.currentTarget.querySelector("input")?.focus()
return
}
setError(null)
setPending(true)
await new Promise((resolve) => setTimeout(resolve, 900))
setPending(false)
if (step === "email") {
setStep("code")
} else {
toast.add({ title: "Signed in", description: email, type: "success" })
setStep("email")
setCode("")
}
}
function restart() {
setStep("email")
setCode("")
setError(null)
}
return (
<div className="@container">
<div className="grid overflow-hidden rounded-xl border bg-background @4xl:min-h-[44rem] @4xl:grid-cols-2">
{/* A dark subtree in both themes; in dark mode a raised surface and a
rule keep it apart from the form. */}
<div className="dark relative hidden flex-col justify-between border-r bg-surface p-10 text-foreground @4xl:flex">
<div className="flex items-center gap-2.5">
<span className="flex size-7 items-center justify-center rounded-md bg-foreground text-background">
<ZapIcon className="size-3.5" aria-hidden="true" />
</span>
<span className="font-semibold tracking-tight">Meridian</span>
</div>
<figure className="flex max-w-md flex-col gap-4">
<blockquote className="text-heading-lg text-balance">
“We replaced three internal UI kits with one system. Our
accessibility bug backlog went to zero in a quarter.”
</blockquote>
<figcaption className="text-sm text-muted-foreground">
Lena Brandt · Head of Platform, Meridian
</figcaption>
</figure>
</div>
<div className="flex items-center justify-center p-6 @md:p-10">
<form
noValidate
onSubmit={submit}
className="flex w-full max-w-sm flex-col gap-8"
>
{step === "email" ? (
<>
<div className="flex flex-col gap-2">
<h2 className="text-heading-xl">Sign in to Meridian</h2>
<p className="text-sm text-muted-foreground">
We'll email you a six-digit code. No password needed.
</p>
</div>
<FieldGroup>
<div className="grid grid-cols-2 gap-2">
<Button variant="outline" type="button">
<GoogleIcon data-icon="inline-start" />
Google
</Button>
<Button variant="outline" type="button">
<GithubIcon data-icon="inline-start" />
GitHub
</Button>
</div>
<FieldSeparator>or continue with email</FieldSeparator>
<Field data-invalid={error ? true : undefined}>
<FieldLabel htmlFor="auth-email">Work email</FieldLabel>
<Input
id="auth-email"
type="email"
autoComplete="email"
placeholder="ada@example.com"
value={email}
onChange={(event) => {
setEmail(event.target.value)
setError(null)
}}
aria-invalid={error ? true : undefined}
aria-describedby={error ? "auth-error" : undefined}
size="lg"
/>
{error && <FieldError id="auth-error">{error}</FieldError>}
</Field>
<Button type="submit" size="lg" loading={pending}>
Continue with email
</Button>
<FieldDescription className="text-center">
By continuing you agree to the{" "}
<a href="https://meridian.example/terms">terms</a> and{" "}
<a href="https://meridian.example/privacy">
privacy policy
</a>
.
</FieldDescription>
</FieldGroup>
</>
) : (
<>
<div className="flex flex-col gap-3">
<span className="flex size-11 items-center justify-center rounded-xl border bg-card text-muted-foreground shadow-raised">
<MailCheckIcon className="size-5" aria-hidden="true" />
</span>
<h2 className="text-heading-xl">Check your inbox</h2>
<p className="text-sm text-muted-foreground">
Enter the code we sent to{" "}
<span className="font-medium wrap-anywhere text-foreground">
{email}
</span>
.
</p>
</div>
<FieldGroup>
<Field data-invalid={error ? true : undefined}>
<FieldLabel htmlFor="auth-code" className="sr-only">
Verification code
</FieldLabel>
<InputOTP
id="auth-code"
maxLength={6}
value={code}
onChange={(value) => {
setCode(value)
setError(null)
}}
aria-invalid={error ? true : undefined}
aria-describedby="auth-code-help"
autoFocus
>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
</InputOTPGroup>
<InputOTPSeparator />
<InputOTPGroup>
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
{error ? (
<FieldError id="auth-code-help">{error}</FieldError>
) : (
<FieldDescription id="auth-code-help">
The code expires in 10 minutes.
</FieldDescription>
)}
</Field>
<Button type="submit" size="lg" loading={pending}>
Verify and sign in
</Button>
{/* Off while the code is checked: going back wouldn't
cancel the sign-in. */}
<Button
type="button"
variant="ghost"
disabled={pending}
onClick={restart}
>
<ArrowLeftIcon data-icon="inline-start" />
Use a different email
</Button>
</FieldGroup>
</>
)}
</form>
</div>
</div>
</div>
)
}import type { Metadata } from "next"
import Authentication from "@/components/authentication/authentication"
import { Toaster } from "@/components/ui/toast"
export const metadata: Metadata = {
title: "Authentication",
description: "Passwordless sign-in with social providers and a one-time code: two steps on one screen.",
}
export default function AuthenticationPage() {
return (
<Toaster>
<main className="mx-auto min-h-dvh w-full max-w-[90rem] p-4 sm:p-8">
<Authentication />
</main>
</Toaster>
)
}