Skip to content
All examples

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
components/authentication/authentication.tsx
"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&apos;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>
  )
}
app/authentication/page.tsx
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>
  )
}