Auth Kit: complete sign-in flow
Seven auth pages in three switchable layouts (centered, split with art, frosted glass): sign in, sign up with a live password checklist, six-digit verification, forgot and reset password, team invite and a three-step onboarding.
// Ballmac UI: Auth Kit template fonts. https://ui.ballmac.com/templates/template-auth-kit
import { Outfit } from "next/font/google"
export const authSans = Outfit({ variable: "--auth-sans", subsets: ["latin"], display: "swap", preload: false })// Ballmac UI: Auth Kit template frame. https://ui.ballmac.com/templates/template-auth-kit
"use client"
import * as React from "react"
import { Eye, EyeOff, Loader2 } from "lucide-react"
import { AuroraBackground } from "@/components/ballmac/aurora-background"
import { authSans } from "@/components/ballmac/templates/auth-kit/auth-kit-fonts"
import { cn } from "@/lib/utils"
type AuthLayout = "centered" | "split" | "glass"
type AuthPage = "sign-in" | "sign-up" | "verify" | "forgot" | "reset" | "invite" | "onboarding"
type AuthHrefs = Record<AuthPage, string>
const defaultHrefs: AuthHrefs = {
"sign-in": "/auth/sign-in",
"sign-up": "/auth/sign-up",
verify: "/auth/verify",
forgot: "/auth/forgot",
reset: "/auth/reset",
invite: "/auth/invite",
onboarding: "/auth/onboarding",
}
/** Auth Kit's palette: soft ivory and ink with one indigo. Friendly, clear and calm. */
const authCss = `
.auth-theme,body:not(:has([data-gallery])):has(.auth-theme){--background:oklch(0.982 0.006 90);--foreground:oklch(0.21 0.03 275);--card:oklch(1 0 0);--card-foreground:oklch(0.21 0.03 275);--popover:oklch(1 0 0);--popover-foreground:oklch(0.21 0.03 275);--primary:oklch(0.46 0.2 275);--primary-foreground:oklch(0.99 0.004 90);--secondary:oklch(0.958 0.01 90);--secondary-foreground:oklch(0.21 0.03 275);--muted:oklch(0.958 0.01 90);--muted-foreground:oklch(0.47 0.03 275);--accent:oklch(0.94 0.014 280);--accent-foreground:oklch(0.21 0.03 275);--border:oklch(0.21 0.03 275 / 12%);--input:oklch(0.21 0.03 275 / 20%);--ring:oklch(0.55 0.2 275);--surface:oklch(0.968 0.01 90);--destructive:oklch(0.53 0.22 25);--chart-1:oklch(0.5 0.2 275);--chart-2:oklch(0.48 0.12 165);--chart-3:oklch(0.62 0.14 75);--chart-4:oklch(0.52 0.19 340);--chart-5:oklch(0.52 0.12 235);--radius:0.875rem}
.dark .auth-theme,.dark body:not(:has([data-gallery])):has(.auth-theme){--background:oklch(0.17 0.022 275);--foreground:oklch(0.95 0.008 90);--card:oklch(0.21 0.026 275);--card-foreground:oklch(0.95 0.008 90);--popover:oklch(0.23 0.028 275);--popover-foreground:oklch(0.95 0.008 90);--primary:oklch(0.76 0.14 275);--primary-foreground:oklch(0.19 0.03 275);--secondary:oklch(0.26 0.028 275);--secondary-foreground:oklch(0.95 0.008 90);--muted:oklch(0.25 0.028 275);--muted-foreground:oklch(0.73 0.025 275);--accent:oklch(0.29 0.034 275);--accent-foreground:oklch(0.95 0.008 90);--border:oklch(1 0 0 / 11%);--input:oklch(1 0 0 / 16%);--ring:oklch(0.74 0.14 275);--surface:oklch(0.19 0.024 275);--destructive:oklch(0.7 0.19 25);--chart-1:oklch(0.76 0.14 275);--chart-2:oklch(0.78 0.13 165);--chart-3:oklch(0.83 0.13 85);--chart-4:oklch(0.76 0.15 340);--chart-5:oklch(0.76 0.11 235)}
body:not(:has([data-gallery])):has(.auth-theme){font-family:var(--auth-sans),ui-sans-serif,system-ui,sans-serif}
`
function AuthMark({ className }: { className?: string }) {
return (
<svg viewBox="0 0 28 28" aria-hidden="true" className={cn("size-8", className)} fill="none">
<rect width="28" height="28" rx="9" fill="var(--primary)" />
<circle cx="14" cy="12" r="3.6" stroke="var(--primary-foreground)" strokeWidth="2" />
<path d="M14 15.6V21M14 18.5h2.6" stroke="var(--primary-foreground)" strokeWidth="2" strokeLinecap="round" />
</svg>
)
}
type AuthFrameProps = React.ComponentProps<"div"> & {
/** How the page is composed. */
layout?: AuthLayout
/** Brand name beside the mark. */
brand?: string
/** Where the brand links to. */
homeHref?: string
}
const quote = { text: "We replaced three login screens with one in an afternoon, and support tickets about passwords dropped by half.", name: "Sasha Petrova", role: "Head of Product, Fernhill" }
/** The page around an auth form: centred card, split with art, or frosted glass over a soft aurora. */
function AuthFrame({ layout = "centered", brand = "Keystone", homeHref = "#", className, style, children, ...props }: AuthFrameProps) {
React.useEffect(() => {
const cls = authSans.variable
if (!cls) return
document.body.classList.add(cls)
return () => document.body.classList.remove(cls)
}, [])
const logo = (
<a href={homeHref} className="focus-visible:ring-ring/50 inline-flex items-center gap-2.5 rounded-lg outline-none focus-visible:ring-[3px]">
<AuthMark /> <span className="text-xl font-semibold tracking-tight">{brand}</span>
</a>
)
return (
<div
data-slot="auth-kit"
data-layout={layout}
className={cn("auth-theme bg-background text-foreground relative isolate min-h-dvh overflow-x-clip", authSans.variable, className)}
style={{ fontFamily: "var(--auth-sans), ui-sans-serif, system-ui, sans-serif", ...style }}
{...props}
>
<style>{authCss}</style>
{layout === "centered" && (
<main className="flex min-h-dvh flex-col items-center justify-center px-4 py-12">
<div aria-hidden="true" className="absolute inset-0 -z-10 [background-image:radial-gradient(var(--border)_1px,transparent_1px)] [background-size:22px_22px] [mask-image:radial-gradient(ellipse_at_center,black,transparent_70%)]" />
<div className="mb-8">{logo}</div>
<div className="bg-card w-full max-w-md rounded-3xl border p-7 shadow-[0_24px_60px_-30px_oklch(0.4_0.1_275/0.35)] sm:p-9">{children}</div>
<p className="text-muted-foreground mt-8 text-xs">Protected by two-factor authentication · <a href="#" className="underline underline-offset-4">Privacy</a></p>
</main>
)}
{layout === "split" && (
<main className="grid min-h-dvh lg:grid-cols-[minmax(0,1fr)_minmax(0,1.05fr)]">
<div className="flex flex-col px-6 py-8 sm:px-12 lg:px-16">
{logo}
<div className="mx-auto flex w-full max-w-sm flex-1 flex-col justify-center py-12">{children}</div>
<p className="text-muted-foreground text-xs">© 2026 {brand}. All rights reserved.</p>
</div>
<aside aria-hidden="true" className="bg-primary text-primary-foreground relative hidden overflow-hidden lg:block">
<div className="absolute inset-0 [background-image:radial-gradient(circle_at_20%_20%,var(--chart-4)_0,transparent_45%),radial-gradient(circle_at_85%_70%,var(--chart-5)_0,transparent_50%)] opacity-45" />
<div className="absolute inset-0 [background-image:linear-gradient(oklch(1_0_0/0.07)_1px,transparent_1px),linear-gradient(90deg,oklch(1_0_0/0.07)_1px,transparent_1px)] [background-size:48px_48px]" />
<figure className="relative flex h-full flex-col justify-end p-14">
<blockquote className="max-w-md text-3xl leading-snug font-medium tracking-[-0.02em] text-balance">“{quote.text}”</blockquote>
<figcaption className="mt-6 text-sm opacity-85"><span className="font-semibold">{quote.name}</span> · {quote.role}</figcaption>
</figure>
</aside>
</main>
)}
{layout === "glass" && (
<main className="flex min-h-dvh flex-col items-center justify-center px-4 py-12">
<AuroraBackground aria-hidden="true" className="absolute inset-0 -z-10" colors={["var(--chart-1)", "var(--chart-4)", "var(--chart-5)"]} intensity={0.6} radialMask={false} />
<div className="mb-8">{logo}</div>
<div className="bg-card/55 w-full max-w-md rounded-3xl border p-7 shadow-[0_30px_80px_-30px_oklch(0.3_0.1_275/0.5)] backdrop-blur-2xl backdrop-saturate-150 sm:p-9">{children}</div>
</main>
)}
</div>
)
}
const inputClass = "bg-background focus-visible:ring-ring/50 placeholder:text-muted-foreground h-11 w-full rounded-xl border px-3.5 text-[15px] outline-none transition-shadow focus-visible:ring-[3px] aria-[invalid=true]:border-destructive"
/** A labelled text field with an inline error. */
function AuthField({ label, error, hint, id, className, ...props }: Omit<React.ComponentProps<"input">, "id"> & { label: string; error?: string; hint?: string; id: string }) {
return (
<div className={className}>
<label htmlFor={id} className="text-sm font-medium">{label}</label>
<input id={id} aria-invalid={!!error} aria-describedby={error ? `${id}-err` : hint ? `${id}-hint` : undefined} className={cn(inputClass, "mt-1.5")} {...props} />
{error ? <p id={`${id}-err`} className="text-destructive mt-1.5 text-sm">{error}</p> : hint ? <p id={`${id}-hint`} className="text-muted-foreground mt-1.5 text-sm">{hint}</p> : null}
</div>
)
}
/** A password field with a show and hide button. */
function AuthPassword({ label = "Password", error, id, ...props }: Omit<React.ComponentProps<"input">, "id" | "type"> & { label?: string; error?: string; id: string }) {
const [show, setShow] = React.useState(false)
return (
<div>
<label htmlFor={id} className="text-sm font-medium">{label}</label>
<div className="relative mt-1.5">
<input id={id} type={show ? "text" : "password"} aria-invalid={!!error} aria-describedby={error ? `${id}-err` : undefined} className={cn(inputClass, "pe-11")} {...props} />
<button type="button" onClick={() => setShow((v) => !v)} aria-label={show ? "Hide password" : "Show password"} aria-pressed={show} className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 absolute top-1/2 end-1.5 inline-flex size-8 -translate-y-1/2 items-center justify-center rounded-lg outline-none focus-visible:ring-[3px]">
{show ? <EyeOff className="size-4" aria-hidden="true" /> : <Eye className="size-4" aria-hidden="true" />}
</button>
</div>
{error && <p id={`${id}-err`} className="text-destructive mt-1.5 text-sm">{error}</p>}
</div>
)
}
/** The main button of a form. */
function AuthButton({ busy, className, children, ...props }: React.ComponentProps<"button"> & { busy?: boolean }) {
return (
<button disabled={busy || props.disabled} className={cn("bg-primary text-primary-foreground focus-visible:ring-ring/50 inline-flex h-11 w-full items-center justify-center gap-2 rounded-xl text-[15px] font-semibold outline-none transition-opacity hover:opacity-90 focus-visible:ring-[3px] disabled:opacity-60", className)} {...props}>
{busy && <Loader2 className="size-4 motion-safe:animate-spin" aria-hidden="true" />}
{children}
</button>
)
}
/** Password rules, scored live. */
const rules = [
{ id: "len", label: "At least 10 characters", test: (p: string) => p.length >= 10 },
{ id: "case", label: "An upper and a lower case letter", test: (p: string) => /[a-z]/.test(p) && /[A-Z]/.test(p) },
{ id: "num", label: "A number", test: (p: string) => /\d/.test(p) },
{ id: "sym", label: "A symbol", test: (p: string) => /[^A-Za-z0-9]/.test(p) },
]
/** A strength bar and checklist for a new password. */
function PasswordStrength({ value, id }: { value: string; id: string }) {
const passed = rules.filter((r) => r.test(value)).length
const label = value.length === 0 ? "Enter a password" : passed <= 1 ? "Weak" : passed <= 3 ? "Fair" : "Strong"
const tone = passed <= 1 ? "bg-destructive" : passed <= 3 ? "bg-chart-3" : "bg-chart-2"
return (
<div id={id} className="mt-3">
<div className="flex gap-1.5" aria-hidden="true">
{rules.map((r, i) => <span key={r.id} className={cn("h-1.5 flex-1 rounded-full transition-colors duration-300 motion-reduce:transition-none", i < passed && value ? tone : "bg-muted")} />)}
</div>
<p className="mt-2 text-sm font-medium" role="status">Strength: {label}</p>
<ul className="text-muted-foreground mt-2 grid gap-1 text-sm">
{rules.map((r) => {
const ok = r.test(value)
return <li key={r.id} className={cn("flex items-center gap-2", ok && "text-foreground")}><span className={cn("flex size-4 items-center justify-center rounded-full border text-[10px]", ok ? "bg-chart-2 text-background border-transparent" : "")} aria-hidden="true">{ok ? "✓" : ""}</span>{r.label}<span className="sr-only">{ok ? " (met)" : " (not met)"}</span></li>
})}
</ul>
</div>
)
}
export { AuthButton, AuthField, AuthFrame, AuthMark, AuthPassword, PasswordStrength, defaultHrefs as authDefaultHrefs, rules as passwordRules, type AuthFrameProps, type AuthHrefs, type AuthLayout, type AuthPage }// Ballmac UI: Auth Kit forms. https://ui.ballmac.com/templates/template-auth-kit
"use client"
import * as React from "react"
import { ArrowLeft, ArrowRight, Check, MailCheck, ShieldCheck } from "lucide-react"
import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from "@/components/ballmac/input-otp"
import { AuthButton, AuthField, AuthPassword, PasswordStrength, authDefaultHrefs, passwordRules, type AuthHrefs } from "@/components/ballmac/templates/auth-kit/auth-kit-theme"
import { cn } from "@/lib/utils"
type FormProps = { hrefs?: Partial<AuthHrefs> }
const h = (hrefs: FormProps["hrefs"]) => ({ ...authDefaultHrefs, ...hrefs })
const link = "text-primary focus-visible:ring-ring/50 rounded font-medium underline-offset-4 outline-none hover:underline focus-visible:ring-[3px]"
const emailOk = (v: string) => /^\S+@\S+\.\S+$/.test(v.trim())
/** A short simulated request so busy and success states are visible. Swap for your API call. */
function useFake() {
const timer = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
React.useEffect(() => () => clearTimeout(timer.current), [])
return (done: () => void, ms = 700) => {
clearTimeout(timer.current)
timer.current = setTimeout(done, ms)
}
}
function Heading({ title, children }: { title: string; children?: React.ReactNode }) {
return (
<header className="mb-7">
<h1 className="text-[1.75rem] leading-tight font-semibold tracking-[-0.03em] text-balance">{title}</h1>
{children && <p className="text-muted-foreground mt-2 text-pretty">{children}</p>}
</header>
)
}
function Success({ title, children, action }: { title: string; children: React.ReactNode; action?: React.ReactNode }) {
return (
<div role="status">
<span className="bg-chart-2/15 text-chart-2 flex size-12 items-center justify-center rounded-2xl"><Check className="size-6" aria-hidden="true" /></span>
<h1 className="mt-5 text-[1.75rem] leading-tight font-semibold tracking-[-0.03em]">{title}</h1>
<p className="text-muted-foreground mt-2 text-pretty">{children}</p>
{action && <div className="mt-6">{action}</div>}
</div>
)
}
/** Sign in with email and password, or a provider. Validates before it submits. */
function SignInForm({ hrefs }: FormProps) {
const links = h(hrefs)
const fake = useFake()
const [errors, setErrors] = React.useState<{ email?: string; password?: string }>({})
const [busy, setBusy] = React.useState(false)
const [done, setDone] = React.useState(false)
function submit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
const f = new FormData(e.currentTarget)
const email = String(f.get("email") ?? "")
const password = String(f.get("password") ?? "")
const next: typeof errors = {}
if (!emailOk(email)) next.email = "Enter an email like you@company.com."
if (!password) next.password = "Enter your password."
setErrors(next)
if (next.email || next.password) {
;(e.currentTarget.elements.namedItem(next.email ? "email" : "password") as HTMLElement | null)?.focus()
return
}
setBusy(true)
fake(() => { setBusy(false); setDone(true) })
}
if (done) return <Success title="You’re in" action={<a className={link} href="#">Continue to your workspace →</a>}>Welcome back. We’ll take you to where you left off.</Success>
return (
<>
<Heading title="Welcome back">Sign in to continue to your workspace.</Heading>
<div className="grid grid-cols-2 gap-3">
{["Google", "GitHub"].map((p) => <button key={p} type="button" className="hover:bg-accent focus-visible:ring-ring/50 h-11 rounded-xl border text-sm font-medium outline-none transition-colors focus-visible:ring-[3px]">{p}</button>)}
</div>
<div className="text-muted-foreground my-6 flex items-center gap-3 text-xs"><span className="bg-border h-px flex-1" />or with email<span className="bg-border h-px flex-1" /></div>
<form onSubmit={submit} noValidate className="space-y-4">
<AuthField id="si-email" name="email" type="email" label="Email" autoComplete="email" error={errors.email} />
<div>
<AuthPassword id="si-pass" name="password" autoComplete="current-password" error={errors.password} />
<div className="mt-2.5 flex items-center justify-between text-sm">
<label className="flex cursor-pointer items-center gap-2"><input type="checkbox" name="remember" className="accent-primary size-4" />Keep me signed in</label>
<a href={links.forgot} className={link}>Forgot password?</a>
</div>
</div>
<AuthButton type="submit" busy={busy}>{busy ? "Signing in…" : "Sign in"}</AuthButton>
</form>
<p className="text-muted-foreground mt-6 text-center text-sm">New here? <a href={links["sign-up"]} className={link}>Create an account</a></p>
</>
)
}
/** Create an account with a live password checklist and a terms checkbox. */
function SignUpForm({ hrefs }: FormProps) {
const links = h(hrefs)
const fake = useFake()
const [pw, setPw] = React.useState("")
const [errors, setErrors] = React.useState<{ name?: string; email?: string; password?: string; terms?: string }>({})
const [busy, setBusy] = React.useState(false)
const [email, setEmail] = React.useState<string | null>(null)
function submit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
const f = new FormData(e.currentTarget)
const name = String(f.get("name") ?? "").trim()
const mail = String(f.get("email") ?? "").trim()
const next: typeof errors = {}
if (!name) next.name = "Tell us your name."
if (!emailOk(mail)) next.email = "Enter an email like you@company.com."
if (!passwordRules.every((r) => r.test(pw))) next.password = "Meet every rule below to continue."
if (!f.get("terms")) next.terms = "Agree to the terms to continue."
setErrors(next)
const first = (["name", "email", "password", "terms"] as const).find((k) => next[k])
if (first) {
;(e.currentTarget.elements.namedItem(first) as HTMLElement | null)?.focus()
return
}
setBusy(true)
fake(() => { setBusy(false); setEmail(mail) })
}
if (email) return <Success title="Check your email" action={<a className={link} href={links.verify}>Enter the 6-digit code →</a>}>We sent a code to <span className="text-foreground font-medium">{email}</span>. It expires in 10 minutes.</Success>
return (
<>
<Heading title="Create your account">Free for 14 days. No card needed.</Heading>
<form onSubmit={submit} noValidate className="space-y-4">
<AuthField id="su-name" name="name" label="Full name" autoComplete="name" error={errors.name} />
<AuthField id="su-email" name="email" type="email" label="Work email" autoComplete="email" error={errors.email} />
<div>
<AuthPassword id="su-pass" name="password" label="Password" autoComplete="new-password" value={pw} onChange={(e) => setPw(e.target.value)} error={errors.password} />
<PasswordStrength id="su-strength" value={pw} />
</div>
<div>
<label className="flex cursor-pointer items-start gap-2.5 text-sm"><input type="checkbox" name="terms" aria-invalid={!!errors.terms} className="accent-primary mt-0.5 size-4" /><span>I agree to the <a href="#" className={link}>Terms</a> and <a href="#" className={link}>Privacy Policy</a>.</span></label>
{errors.terms && <p className="text-destructive mt-1.5 text-sm">{errors.terms}</p>}
</div>
<AuthButton type="submit" busy={busy}>{busy ? "Creating account…" : "Create account"}</AuthButton>
</form>
<p className="text-muted-foreground mt-6 text-center text-sm">Already have an account? <a href={links["sign-in"]} className={link}>Sign in</a></p>
</>
)
}
/** Verify with a 6-digit code: auto-submits when complete, and offers a resend after a countdown. */
function VerifyForm({ hrefs }: FormProps) {
const links = h(hrefs)
const [code, setCode] = React.useState("")
const [state, setState] = React.useState<"idle" | "error" | "ok">("idle")
const [wait, setWait] = React.useState(30)
React.useEffect(() => {
if (wait <= 0) return
const id = setTimeout(() => setWait((w) => w - 1), 1000)
return () => clearTimeout(id)
}, [wait])
if (state === "ok") return <Success title="Email verified" action={<a className={link} href={links.onboarding}>Set up your workspace →</a>}>Thanks. Your account is ready.</Success>
return (
<>
<span className="bg-primary/10 text-primary mb-5 flex size-12 items-center justify-center rounded-2xl"><MailCheck className="size-6" aria-hidden="true" /></span>
<Heading title="Enter your code">We sent a 6-digit code to <span className="text-foreground font-medium">mina@fieldnote.example</span>. Try 123456.</Heading>
<label htmlFor="vf-code" className="sr-only">6-digit code</label>
<InputOTP id="vf-code" maxLength={6} value={code} aria-invalid={state === "error" || undefined} aria-describedby="vf-status" pattern="^[0-9]*$" onChange={(v) => { setCode(v); setState("idle") }} onComplete={(v) => setState(v === "123456" ? "ok" : "error")} containerClassName="justify-center">
<InputOTPGroup>{[0, 1, 2].map((i) => <InputOTPSlot key={i} index={i} invalid={state === "error"} className="size-12 text-lg" />)}</InputOTPGroup>
<InputOTPSeparator />
<InputOTPGroup>{[3, 4, 5].map((i) => <InputOTPSlot key={i} index={i} invalid={state === "error"} className="size-12 text-lg" />)}</InputOTPGroup>
</InputOTP>
<p id="vf-status" role="status" className={cn("mt-4 text-center text-sm", state === "error" ? "text-destructive" : "text-muted-foreground")}>{state === "error" ? "That code isn’t right. Check it and try again." : "Paste the code, or type it in."}</p>
<p className="text-muted-foreground mt-6 text-center text-sm">
Didn’t get it?{" "}
<button type="button" disabled={wait > 0} onClick={() => { setWait(30); setCode(""); setState("idle") }} className={cn(link, "disabled:text-muted-foreground disabled:no-underline")}>{wait > 0 ? `Resend in ${wait}s` : "Resend the code"}</button>
</p>
<p className="mt-3 text-center text-sm"><a href={links["sign-in"]} className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1.5"><ArrowLeft className="size-3.5 rtl:rotate-180" aria-hidden="true" />Back to sign in</a></p>
</>
)
}
/** Ask for a reset link. */
function ForgotForm({ hrefs }: FormProps) {
const links = h(hrefs)
const fake = useFake()
const [error, setError] = React.useState("")
const [busy, setBusy] = React.useState(false)
const [sent, setSent] = React.useState<string | null>(null)
function submit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
const mail = String(new FormData(e.currentTarget).get("email") ?? "").trim()
if (!emailOk(mail)) {
setError("Enter an email like you@company.com.")
;(e.currentTarget.elements.namedItem("email") as HTMLElement | null)?.focus()
return
}
setError("")
setBusy(true)
fake(() => { setBusy(false); setSent(mail) })
}
if (sent) return <Success title="Check your email" action={<button type="button" onClick={() => setSent(null)} className={link}>Use a different email</button>}>If an account exists for <span className="text-foreground font-medium">{sent}</span>, a reset link is on its way. It works for one hour.</Success>
return (
<>
<Heading title="Forgot your password?">No problem. Tell us your email and we’ll send a link to choose a new one.</Heading>
<form onSubmit={submit} noValidate className="space-y-4">
<AuthField id="fp-email" name="email" type="email" label="Email" autoComplete="email" error={error} />
<AuthButton type="submit" busy={busy}>{busy ? "Sending…" : "Send reset link"}</AuthButton>
</form>
<p className="mt-6 text-center text-sm"><a href={links["sign-in"]} className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1.5"><ArrowLeft className="size-3.5 rtl:rotate-180" aria-hidden="true" />Back to sign in</a></p>
</>
)
}
/** Choose a new password, with confirmation. */
function ResetForm({ hrefs }: FormProps) {
const links = h(hrefs)
const fake = useFake()
const [pw, setPw] = React.useState("")
const [errors, setErrors] = React.useState<{ password?: string; confirm?: string }>({})
const [busy, setBusy] = React.useState(false)
const [done, setDone] = React.useState(false)
function submit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
const confirm = String(new FormData(e.currentTarget).get("confirm") ?? "")
const next: typeof errors = {}
if (!passwordRules.every((r) => r.test(pw))) next.password = "Meet every rule below to continue."
else if (confirm !== pw) next.confirm = "The passwords don’t match."
setErrors(next)
if (next.password || next.confirm) {
;(e.currentTarget.elements.namedItem(next.password ? "password" : "confirm") as HTMLElement | null)?.focus()
return
}
setBusy(true)
fake(() => { setBusy(false); setDone(true) })
}
if (done) return <Success title="Password updated" action={<a className={link} href={links["sign-in"]}>Sign in with your new password →</a>}>You’ve been signed out everywhere else for your safety.</Success>
return (
<>
<Heading title="Choose a new password">Pick something you haven’t used before.</Heading>
<form onSubmit={submit} noValidate className="space-y-4">
<div>
<AuthPassword id="rs-pass" name="password" label="New password" autoComplete="new-password" value={pw} onChange={(e) => setPw(e.target.value)} error={errors.password} />
<PasswordStrength id="rs-strength" value={pw} />
</div>
<AuthPassword id="rs-confirm" name="confirm" label="Confirm password" autoComplete="new-password" error={errors.confirm} />
<AuthButton type="submit" busy={busy}>{busy ? "Saving…" : "Update password"}</AuthButton>
</form>
</>
)
}
/** Accept an invitation to a team. */
function InviteForm({ hrefs }: FormProps) {
const fake = useFake()
const [pw, setPw] = React.useState("")
const [error, setError] = React.useState("")
const [busy, setBusy] = React.useState(false)
const [done, setDone] = React.useState(false)
function submit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
if (!passwordRules.every((r) => r.test(pw))) {
setError("Meet every rule below to continue.")
;(e.currentTarget.elements.namedItem("password") as HTMLElement | null)?.focus()
return
}
setError("")
setBusy(true)
fake(() => { setBusy(false); setDone(true) })
}
const links = h(hrefs)
if (done) return <Success title="Welcome to Fieldnote Goods" action={<a className={link} href={links.onboarding}>Finish setting up →</a>}>You’ve joined as an editor.</Success>
return (
<>
<div className="bg-secondary mb-6 flex items-center gap-3 rounded-2xl p-3.5">
<span className="bg-chart-4/25 flex size-11 items-center justify-center rounded-xl text-sm font-semibold" aria-hidden="true">FG</span>
<p className="text-sm text-pretty"><span className="font-semibold">Maya Okafor</span> invited you to join <span className="font-semibold">Fieldnote Goods</span> as an editor.</p>
</div>
<Heading title="Accept your invitation">Create a password for <span className="text-foreground font-medium">dev@fieldnote.example</span>.</Heading>
<form onSubmit={submit} noValidate className="space-y-4">
<AuthField id="iv-name" name="name" label="Full name" defaultValue="Dev Patel" autoComplete="name" />
<div>
<AuthPassword id="iv-pass" name="password" autoComplete="new-password" value={pw} onChange={(e) => setPw(e.target.value)} error={error} />
<PasswordStrength id="iv-strength" value={pw} />
</div>
<AuthButton type="submit" busy={busy}>{busy ? "Joining…" : "Join Fieldnote Goods"}</AuthButton>
</form>
</>
)
}
const steps = ["You", "Workspace", "Team"]
/** Three short steps to set up a workspace, with progress and a slug that follows the name. */
function OnboardingForm({ hrefs }: FormProps) {
const links = h(hrefs)
const [step, setStep] = React.useState(0)
const [name, setName] = React.useState("Mina Kovac")
const [role, setRole] = React.useState("Design")
const [ws, setWs] = React.useState("Fieldnote Goods")
const [invites, setInvites] = React.useState("")
const [error, setError] = React.useState("")
const [done, setDone] = React.useState(false)
const head = React.useRef<HTMLHeadingElement>(null)
const slug = ws.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")
const emails = invites.split(/[\s,]+/).filter(Boolean)
const roles = ["Design", "Engineering", "Product", "Marketing", "Operations"]
function next() {
if (step === 0 && !name.trim()) return setError("Tell us your name.")
if (step === 1 && !ws.trim()) return setError("Give your workspace a name.")
if (step === 2 && emails.some((m) => !emailOk(m))) return setError("One of those doesn’t look like an email address.")
setError("")
if (step === 2) return setDone(true)
setStep((s) => s + 1)
requestAnimationFrame(() => head.current?.focus())
}
if (done) return <Success title="You’re all set" action={<a className={link} href="#">Open {ws || "your workspace"} →</a>}>{emails.length > 0 ? `We sent ${emails.length} invitation${emails.length === 1 ? "" : "s"}.` : "You can invite your team any time."}</Success>
return (
<>
<ol className="mb-7 flex items-center gap-2" aria-label="Setup progress">
{steps.map((s, i) => (
<li key={s} aria-current={i === step ? "step" : undefined} className="flex flex-1 items-center gap-2 text-xs font-medium">
<span className={cn("flex size-6 shrink-0 items-center justify-center rounded-full border text-[11px]", i < step ? "bg-primary text-primary-foreground border-transparent" : i === step ? "border-primary text-primary" : "text-muted-foreground")}>{i < step ? <Check className="size-3.5" aria-label="done" /> : i + 1}</span>
<span className={i === step ? "text-foreground" : "text-muted-foreground"}>{s}</span>
{i < steps.length - 1 && <span className="bg-border h-px flex-1" aria-hidden="true" />}
</li>
))}
</ol>
<header className="mb-6">
<h1 ref={head} tabIndex={-1} className="text-[1.75rem] leading-tight font-semibold tracking-[-0.03em] outline-none">{step === 0 ? "Tell us about you" : step === 1 ? "Name your workspace" : "Invite your team"}</h1>
<p className="text-muted-foreground mt-2 text-pretty">{step === 0 ? "So your teammates know who you are." : step === 1 ? "You can change this any time." : "They’ll get an email with a link to join. You can skip this."}</p>
</header>
<div className="space-y-4">
{step === 0 && (
<>
<AuthField id="ob-name" label="Full name" value={name} onChange={(e) => setName(e.target.value)} autoComplete="name" error={error} />
<fieldset><legend className="text-sm font-medium">Your team</legend>
<div className="mt-2 flex flex-wrap gap-2">
{roles.map((r) => <label key={r} className={cn("focus-within:ring-ring/50 cursor-pointer rounded-full border px-3.5 py-1.5 text-sm transition-colors focus-within:ring-[3px]", role === r ? "bg-primary text-primary-foreground border-transparent" : "hover:bg-accent")}><input type="radio" name="role" value={r} checked={role === r} onChange={() => setRole(r)} className="sr-only" />{r}</label>)}
</div>
</fieldset>
</>
)}
{step === 1 && (
<>
<AuthField id="ob-ws" label="Workspace name" value={ws} onChange={(e) => setWs(e.target.value)} error={error} hint={slug ? `Your address: ${slug}.keystone.app` : undefined} />
</>
)}
{step === 2 && (
<div>
<label htmlFor="ob-inv" className="text-sm font-medium">Email addresses</label>
<textarea id="ob-inv" rows={3} value={invites} onChange={(e) => setInvites(e.target.value)} placeholder="dev@company.com, priya@company.com" aria-invalid={!!error} className="bg-background focus-visible:ring-ring/50 placeholder:text-muted-foreground mt-1.5 w-full resize-none rounded-xl border p-3.5 text-[15px] outline-none focus-visible:ring-[3px] aria-[invalid=true]:border-destructive" />
<p className={cn("mt-1.5 text-sm", error ? "text-destructive" : "text-muted-foreground")} role="status">{error || (emails.length ? `${emails.length} ${emails.length === 1 ? "person" : "people"} will be invited` : "Separate addresses with commas or spaces.")}</p>
</div>
)}
</div>
<div className="mt-7 flex items-center justify-between gap-3">
{step > 0 ? <button type="button" onClick={() => { setError(""); setStep((s) => s - 1) }} className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex items-center gap-1.5 rounded-lg px-1 text-sm outline-none focus-visible:ring-[3px]"><ArrowLeft className="size-4 rtl:rotate-180" aria-hidden="true" />Back</button> : <button type="button" onClick={() => setDone(true)} className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 rounded px-1 text-sm outline-none focus-visible:ring-[3px]">Skip for now</button>}
<AuthButton type="button" onClick={next} className="w-auto px-6">{step === 2 ? (emails.length ? "Send invitations" : "Finish") : "Continue"}{step < 2 && <ArrowRight className="size-4 rtl:rotate-180" aria-hidden="true" />}</AuthButton>
</div>
<p className="text-muted-foreground mt-6 flex items-center justify-center gap-1.5 text-xs"><ShieldCheck className="size-3.5" aria-hidden="true" />Step {step + 1} of {steps.length}</p>
</>
)
}
export { ForgotForm, InviteForm, OnboardingForm, ResetForm, SignInForm, SignUpForm, VerifyForm, type FormProps }// Ballmac UI: Auth Kit pages. https://ui.ballmac.com/templates/template-auth-kit
"use client"
import * as React from "react"
import { SegmentedControl, SegmentedControlItem } from "@/components/ballmac/segmented-control"
import { ForgotForm, InviteForm, OnboardingForm, ResetForm, SignInForm, SignUpForm, VerifyForm } from "@/components/ballmac/templates/auth-kit/auth-kit-forms"
import { AuthFrame, authDefaultHrefs, type AuthHrefs, type AuthLayout, type AuthPage } from "@/components/ballmac/templates/auth-kit/auth-kit-theme"
import { cn } from "@/lib/utils"
const forms: Record<AuthPage, React.ComponentType<{ hrefs?: Partial<AuthHrefs> }>> = {
"sign-in": SignInForm,
"sign-up": SignUpForm,
verify: VerifyForm,
forgot: ForgotForm,
reset: ResetForm,
invite: InviteForm,
onboarding: OnboardingForm,
}
const labels: Record<AuthPage, string> = { "sign-in": "Sign in", "sign-up": "Sign up", verify: "Verify", forgot: "Forgot", reset: "Reset", invite: "Invite", onboarding: "Onboarding" }
const defaultLayout: Record<AuthPage, AuthLayout> = { "sign-in": "split", "sign-up": "centered", verify: "glass", forgot: "centered", reset: "centered", invite: "split", onboarding: "glass" }
type AuthKitPageProps = React.ComponentProps<"div"> & {
/** Which page to show. */
page: AuthPage
/** How the page is composed. Defaults to the layout that suits the page. */
layout?: AuthLayout
/** Override where pages live (used by previews). */
hrefs?: Partial<AuthHrefs>
}
/** One page of the auth flow in a chosen layout. */
function AuthKitPage({ page, layout, hrefs, ...props }: AuthKitPageProps) {
const Form = forms[page]
return (
<AuthFrame layout={layout ?? defaultLayout[page]} {...props}>
<Form hrefs={hrefs} />
</AuthFrame>
)
}
type AuthKitPlaygroundProps = React.ComponentProps<"div"> & {
/** Page shown first. */
defaultPage?: AuthPage
/** Layout shown first. */
defaultLayout?: AuthLayout
}
/** Every page and every layout in one place: pick a flow and a layout from the bar and see the result. */
function AuthKitPlayground({ defaultPage = "sign-in", defaultLayout: initialLayout = "split", ...props }: AuthKitPlaygroundProps) {
const [page, setPage] = React.useState<AuthPage>(defaultPage)
const [layout, setLayout] = React.useState<AuthLayout>(initialLayout)
const Form = forms[page]
// Links between forms switch the page here instead of navigating away.
const hrefs = Object.fromEntries((Object.keys(forms) as AuthPage[]).map((k) => [k, `#${k}`])) as AuthHrefs
React.useEffect(() => {
const onHash = () => {
const k = window.location.hash.slice(1) as AuthPage
if (k in forms) setPage(k)
}
window.addEventListener("hashchange", onHash)
return () => window.removeEventListener("hashchange", onHash)
}, [])
return (
<AuthFrame layout={layout} {...props}>
<Form key={page} hrefs={hrefs} />
<div role="region" aria-label="Auth Kit controls" className="bg-popover/90 fixed inset-x-3 top-3 z-50 mx-auto flex max-w-3xl flex-col gap-3 rounded-2xl border p-3 shadow-[0_20px_50px_-12px_rgb(0_0_0/0.35)] backdrop-blur-xl sm:flex-row sm:items-center">
<div role="group" aria-label="Page" className="flex gap-1 overflow-x-auto [scrollbar-width:none]">
{(Object.keys(forms) as AuthPage[]).map((k) => (
<button key={k} type="button" aria-pressed={page === k} onClick={() => setPage(k)} className={cn("focus-visible:ring-ring/50 h-8 shrink-0 rounded-lg px-3 text-[13px] font-medium outline-none transition-colors focus-visible:ring-[3px]", page === k ? "bg-primary text-primary-foreground" : "hover:bg-accent text-muted-foreground")}>{labels[k]}</button>
))}
</div>
<SegmentedControl aria-label="Layout" value={layout} onValueChange={(v) => setLayout(v as AuthLayout)} size="sm" className="sm:ms-auto">
<SegmentedControlItem value="centered">Centered</SegmentedControlItem>
<SegmentedControlItem value="split">Split</SegmentedControlItem>
<SegmentedControlItem value="glass">Glass</SegmentedControlItem>
</SegmentedControl>
</div>
</AuthFrame>
)
}
export { AuthKitPage, AuthKitPlayground, authDefaultHrefs, type AuthKitPageProps, type AuthKitPlaygroundProps }// Ballmac UI: Auth Kit template route. https://ui.ballmac.com/templates/template-auth-kit
import type { Metadata } from "next"
import { AuthKitPage } from "@/components/ballmac/templates/auth-kit/auth-kit-pages"
export const metadata: Metadata = {
title: "Sign in",
description: "Welcome back. Sign in to continue.",
}
export default function Page() {
return <AuthKitPage page="sign-in" />
}// Ballmac UI: Auth Kit template route. https://ui.ballmac.com/templates/template-auth-kit
import type { Metadata } from "next"
import { AuthKitPage } from "@/components/ballmac/templates/auth-kit/auth-kit-pages"
export const metadata: Metadata = {
title: "Create your account",
description: "Free for 14 days. No card needed.",
}
export default function Page() {
return <AuthKitPage page="sign-up" />
}// Ballmac UI: Auth Kit template route. https://ui.ballmac.com/templates/template-auth-kit
import type { Metadata } from "next"
import { AuthKitPage } from "@/components/ballmac/templates/auth-kit/auth-kit-pages"
export const metadata: Metadata = {
title: "Verify your email",
description: "Enter the 6-digit code we sent you.",
}
export default function Page() {
return <AuthKitPage page="verify" />
}// Ballmac UI: Auth Kit template route. https://ui.ballmac.com/templates/template-auth-kit
import type { Metadata } from "next"
import { AuthKitPage } from "@/components/ballmac/templates/auth-kit/auth-kit-pages"
export const metadata: Metadata = {
title: "Forgot your password?",
description: "Get a link to choose a new password.",
}
export default function Page() {
return <AuthKitPage page="forgot" />
}// Ballmac UI: Auth Kit template route. https://ui.ballmac.com/templates/template-auth-kit
import type { Metadata } from "next"
import { AuthKitPage } from "@/components/ballmac/templates/auth-kit/auth-kit-pages"
export const metadata: Metadata = {
title: "Choose a new password",
description: "Pick a password you haven't used before.",
}
export default function Page() {
return <AuthKitPage page="reset" />
}// Ballmac UI: Auth Kit template route. https://ui.ballmac.com/templates/template-auth-kit
import type { Metadata } from "next"
import { AuthKitPage } from "@/components/ballmac/templates/auth-kit/auth-kit-pages"
export const metadata: Metadata = {
title: "Accept your invitation",
description: "Join your team's workspace.",
}
export default function Page() {
return <AuthKitPage page="invite" />
}// Ballmac UI: Auth Kit template route. https://ui.ballmac.com/templates/template-auth-kit
import type { Metadata } from "next"
import { AuthKitPage } from "@/components/ballmac/templates/auth-kit/auth-kit-pages"
export const metadata: Metadata = {
title: "Set up your workspace",
description: "Three short steps to get started.",
}
export default function Page() {
return <AuthKitPage page="onboarding" />
}Installation
$ pnpm dlx shadcn@latest add @ballmac/template-auth-kitInstall the dependencies.
$ pnpm add lucide-reactAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/aurora-background @ballmac/input-otp @ballmac/segmented-controlCopy the source into your project.
components/ballmac/templates/auth-kit/auth-kit-fonts.ts// Ballmac UI: Auth Kit template fonts. https://ui.ballmac.com/templates/template-auth-kit import { Outfit } from "next/font/google" export const authSans = Outfit({ variable: "--auth-sans", subsets: ["latin"], display: "swap", preload: false })components/ballmac/templates/auth-kit/auth-kit-theme.tsx// Ballmac UI: Auth Kit template frame. https://ui.ballmac.com/templates/template-auth-kit "use client" import * as React from "react" import { Eye, EyeOff, Loader2 } from "lucide-react" import { AuroraBackground } from "@/components/ballmac/aurora-background" import { authSans } from "@/components/ballmac/templates/auth-kit/auth-kit-fonts" import { cn } from "@/lib/utils" type AuthLayout = "centered" | "split" | "glass" type AuthPage = "sign-in" | "sign-up" | "verify" | "forgot" | "reset" | "invite" | "onboarding" type AuthHrefs = Record<AuthPage, string> const defaultHrefs: AuthHrefs = { "sign-in": "/auth/sign-in", "sign-up": "/auth/sign-up", verify: "/auth/verify", forgot: "/auth/forgot", reset: "/auth/reset", invite: "/auth/invite", onboarding: "/auth/onboarding", } /** Auth Kit's palette: soft ivory and ink with one indigo. Friendly, clear and calm. */ const authCss = ` .auth-theme,body:not(:has([data-gallery])):has(.auth-theme){--background:oklch(0.982 0.006 90);--foreground:oklch(0.21 0.03 275);--card:oklch(1 0 0);--card-foreground:oklch(0.21 0.03 275);--popover:oklch(1 0 0);--popover-foreground:oklch(0.21 0.03 275);--primary:oklch(0.46 0.2 275);--primary-foreground:oklch(0.99 0.004 90);--secondary:oklch(0.958 0.01 90);--secondary-foreground:oklch(0.21 0.03 275);--muted:oklch(0.958 0.01 90);--muted-foreground:oklch(0.47 0.03 275);--accent:oklch(0.94 0.014 280);--accent-foreground:oklch(0.21 0.03 275);--border:oklch(0.21 0.03 275 / 12%);--input:oklch(0.21 0.03 275 / 20%);--ring:oklch(0.55 0.2 275);--surface:oklch(0.968 0.01 90);--destructive:oklch(0.53 0.22 25);--chart-1:oklch(0.5 0.2 275);--chart-2:oklch(0.48 0.12 165);--chart-3:oklch(0.62 0.14 75);--chart-4:oklch(0.52 0.19 340);--chart-5:oklch(0.52 0.12 235);--radius:0.875rem} .dark .auth-theme,.dark body:not(:has([data-gallery])):has(.auth-theme){--background:oklch(0.17 0.022 275);--foreground:oklch(0.95 0.008 90);--card:oklch(0.21 0.026 275);--card-foreground:oklch(0.95 0.008 90);--popover:oklch(0.23 0.028 275);--popover-foreground:oklch(0.95 0.008 90);--primary:oklch(0.76 0.14 275);--primary-foreground:oklch(0.19 0.03 275);--secondary:oklch(0.26 0.028 275);--secondary-foreground:oklch(0.95 0.008 90);--muted:oklch(0.25 0.028 275);--muted-foreground:oklch(0.73 0.025 275);--accent:oklch(0.29 0.034 275);--accent-foreground:oklch(0.95 0.008 90);--border:oklch(1 0 0 / 11%);--input:oklch(1 0 0 / 16%);--ring:oklch(0.74 0.14 275);--surface:oklch(0.19 0.024 275);--destructive:oklch(0.7 0.19 25);--chart-1:oklch(0.76 0.14 275);--chart-2:oklch(0.78 0.13 165);--chart-3:oklch(0.83 0.13 85);--chart-4:oklch(0.76 0.15 340);--chart-5:oklch(0.76 0.11 235)} body:not(:has([data-gallery])):has(.auth-theme){font-family:var(--auth-sans),ui-sans-serif,system-ui,sans-serif} ` function AuthMark({ className }: { className?: string }) { return ( <svg viewBox="0 0 28 28" aria-hidden="true" className={cn("size-8", className)} fill="none"> <rect width="28" height="28" rx="9" fill="var(--primary)" /> <circle cx="14" cy="12" r="3.6" stroke="var(--primary-foreground)" strokeWidth="2" /> <path d="M14 15.6V21M14 18.5h2.6" stroke="var(--primary-foreground)" strokeWidth="2" strokeLinecap="round" /> </svg> ) } type AuthFrameProps = React.ComponentProps<"div"> & { /** How the page is composed. */ layout?: AuthLayout /** Brand name beside the mark. */ brand?: string /** Where the brand links to. */ homeHref?: string } const quote = { text: "We replaced three login screens with one in an afternoon, and support tickets about passwords dropped by half.", name: "Sasha Petrova", role: "Head of Product, Fernhill" } /** The page around an auth form: centred card, split with art, or frosted glass over a soft aurora. */ function AuthFrame({ layout = "centered", brand = "Keystone", homeHref = "#", className, style, children, ...props }: AuthFrameProps) { React.useEffect(() => { const cls = authSans.variable if (!cls) return document.body.classList.add(cls) return () => document.body.classList.remove(cls) }, []) const logo = ( <a href={homeHref} className="focus-visible:ring-ring/50 inline-flex items-center gap-2.5 rounded-lg outline-none focus-visible:ring-[3px]"> <AuthMark /> <span className="text-xl font-semibold tracking-tight">{brand}</span> </a> ) return ( <div data-slot="auth-kit" data-layout={layout} className={cn("auth-theme bg-background text-foreground relative isolate min-h-dvh overflow-x-clip", authSans.variable, className)} style={{ fontFamily: "var(--auth-sans), ui-sans-serif, system-ui, sans-serif", ...style }} {...props} > <style>{authCss}</style> {layout === "centered" && ( <main className="flex min-h-dvh flex-col items-center justify-center px-4 py-12"> <div aria-hidden="true" className="absolute inset-0 -z-10 [background-image:radial-gradient(var(--border)_1px,transparent_1px)] [background-size:22px_22px] [mask-image:radial-gradient(ellipse_at_center,black,transparent_70%)]" /> <div className="mb-8">{logo}</div> <div className="bg-card w-full max-w-md rounded-3xl border p-7 shadow-[0_24px_60px_-30px_oklch(0.4_0.1_275/0.35)] sm:p-9">{children}</div> <p className="text-muted-foreground mt-8 text-xs">Protected by two-factor authentication · <a href="#" className="underline underline-offset-4">Privacy</a></p> </main> )} {layout === "split" && ( <main className="grid min-h-dvh lg:grid-cols-[minmax(0,1fr)_minmax(0,1.05fr)]"> <div className="flex flex-col px-6 py-8 sm:px-12 lg:px-16"> {logo} <div className="mx-auto flex w-full max-w-sm flex-1 flex-col justify-center py-12">{children}</div> <p className="text-muted-foreground text-xs">© 2026 {brand}. All rights reserved.</p> </div> <aside aria-hidden="true" className="bg-primary text-primary-foreground relative hidden overflow-hidden lg:block"> <div className="absolute inset-0 [background-image:radial-gradient(circle_at_20%_20%,var(--chart-4)_0,transparent_45%),radial-gradient(circle_at_85%_70%,var(--chart-5)_0,transparent_50%)] opacity-45" /> <div className="absolute inset-0 [background-image:linear-gradient(oklch(1_0_0/0.07)_1px,transparent_1px),linear-gradient(90deg,oklch(1_0_0/0.07)_1px,transparent_1px)] [background-size:48px_48px]" /> <figure className="relative flex h-full flex-col justify-end p-14"> <blockquote className="max-w-md text-3xl leading-snug font-medium tracking-[-0.02em] text-balance">“{quote.text}”</blockquote> <figcaption className="mt-6 text-sm opacity-85"><span className="font-semibold">{quote.name}</span> · {quote.role}</figcaption> </figure> </aside> </main> )} {layout === "glass" && ( <main className="flex min-h-dvh flex-col items-center justify-center px-4 py-12"> <AuroraBackground aria-hidden="true" className="absolute inset-0 -z-10" colors={["var(--chart-1)", "var(--chart-4)", "var(--chart-5)"]} intensity={0.6} radialMask={false} /> <div className="mb-8">{logo}</div> <div className="bg-card/55 w-full max-w-md rounded-3xl border p-7 shadow-[0_30px_80px_-30px_oklch(0.3_0.1_275/0.5)] backdrop-blur-2xl backdrop-saturate-150 sm:p-9">{children}</div> </main> )} </div> ) } const inputClass = "bg-background focus-visible:ring-ring/50 placeholder:text-muted-foreground h-11 w-full rounded-xl border px-3.5 text-[15px] outline-none transition-shadow focus-visible:ring-[3px] aria-[invalid=true]:border-destructive" /** A labelled text field with an inline error. */ function AuthField({ label, error, hint, id, className, ...props }: Omit<React.ComponentProps<"input">, "id"> & { label: string; error?: string; hint?: string; id: string }) { return ( <div className={className}> <label htmlFor={id} className="text-sm font-medium">{label}</label> <input id={id} aria-invalid={!!error} aria-describedby={error ? `${id}-err` : hint ? `${id}-hint` : undefined} className={cn(inputClass, "mt-1.5")} {...props} /> {error ? <p id={`${id}-err`} className="text-destructive mt-1.5 text-sm">{error}</p> : hint ? <p id={`${id}-hint`} className="text-muted-foreground mt-1.5 text-sm">{hint}</p> : null} </div> ) } /** A password field with a show and hide button. */ function AuthPassword({ label = "Password", error, id, ...props }: Omit<React.ComponentProps<"input">, "id" | "type"> & { label?: string; error?: string; id: string }) { const [show, setShow] = React.useState(false) return ( <div> <label htmlFor={id} className="text-sm font-medium">{label}</label> <div className="relative mt-1.5"> <input id={id} type={show ? "text" : "password"} aria-invalid={!!error} aria-describedby={error ? `${id}-err` : undefined} className={cn(inputClass, "pe-11")} {...props} /> <button type="button" onClick={() => setShow((v) => !v)} aria-label={show ? "Hide password" : "Show password"} aria-pressed={show} className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 absolute top-1/2 end-1.5 inline-flex size-8 -translate-y-1/2 items-center justify-center rounded-lg outline-none focus-visible:ring-[3px]"> {show ? <EyeOff className="size-4" aria-hidden="true" /> : <Eye className="size-4" aria-hidden="true" />} </button> </div> {error && <p id={`${id}-err`} className="text-destructive mt-1.5 text-sm">{error}</p>} </div> ) } /** The main button of a form. */ function AuthButton({ busy, className, children, ...props }: React.ComponentProps<"button"> & { busy?: boolean }) { return ( <button disabled={busy || props.disabled} className={cn("bg-primary text-primary-foreground focus-visible:ring-ring/50 inline-flex h-11 w-full items-center justify-center gap-2 rounded-xl text-[15px] font-semibold outline-none transition-opacity hover:opacity-90 focus-visible:ring-[3px] disabled:opacity-60", className)} {...props}> {busy && <Loader2 className="size-4 motion-safe:animate-spin" aria-hidden="true" />} {children} </button> ) } /** Password rules, scored live. */ const rules = [ { id: "len", label: "At least 10 characters", test: (p: string) => p.length >= 10 }, { id: "case", label: "An upper and a lower case letter", test: (p: string) => /[a-z]/.test(p) && /[A-Z]/.test(p) }, { id: "num", label: "A number", test: (p: string) => /\d/.test(p) }, { id: "sym", label: "A symbol", test: (p: string) => /[^A-Za-z0-9]/.test(p) }, ] /** A strength bar and checklist for a new password. */ function PasswordStrength({ value, id }: { value: string; id: string }) { const passed = rules.filter((r) => r.test(value)).length const label = value.length === 0 ? "Enter a password" : passed <= 1 ? "Weak" : passed <= 3 ? "Fair" : "Strong" const tone = passed <= 1 ? "bg-destructive" : passed <= 3 ? "bg-chart-3" : "bg-chart-2" return ( <div id={id} className="mt-3"> <div className="flex gap-1.5" aria-hidden="true"> {rules.map((r, i) => <span key={r.id} className={cn("h-1.5 flex-1 rounded-full transition-colors duration-300 motion-reduce:transition-none", i < passed && value ? tone : "bg-muted")} />)} </div> <p className="mt-2 text-sm font-medium" role="status">Strength: {label}</p> <ul className="text-muted-foreground mt-2 grid gap-1 text-sm"> {rules.map((r) => { const ok = r.test(value) return <li key={r.id} className={cn("flex items-center gap-2", ok && "text-foreground")}><span className={cn("flex size-4 items-center justify-center rounded-full border text-[10px]", ok ? "bg-chart-2 text-background border-transparent" : "")} aria-hidden="true">{ok ? "✓" : ""}</span>{r.label}<span className="sr-only">{ok ? " (met)" : " (not met)"}</span></li> })} </ul> </div> ) } export { AuthButton, AuthField, AuthFrame, AuthMark, AuthPassword, PasswordStrength, defaultHrefs as authDefaultHrefs, rules as passwordRules, type AuthFrameProps, type AuthHrefs, type AuthLayout, type AuthPage }components/ballmac/templates/auth-kit/auth-kit-forms.tsx// Ballmac UI: Auth Kit forms. https://ui.ballmac.com/templates/template-auth-kit "use client" import * as React from "react" import { ArrowLeft, ArrowRight, Check, MailCheck, ShieldCheck } from "lucide-react" import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from "@/components/ballmac/input-otp" import { AuthButton, AuthField, AuthPassword, PasswordStrength, authDefaultHrefs, passwordRules, type AuthHrefs } from "@/components/ballmac/templates/auth-kit/auth-kit-theme" import { cn } from "@/lib/utils" type FormProps = { hrefs?: Partial<AuthHrefs> } const h = (hrefs: FormProps["hrefs"]) => ({ ...authDefaultHrefs, ...hrefs }) const link = "text-primary focus-visible:ring-ring/50 rounded font-medium underline-offset-4 outline-none hover:underline focus-visible:ring-[3px]" const emailOk = (v: string) => /^\S+@\S+\.\S+$/.test(v.trim()) /** A short simulated request so busy and success states are visible. Swap for your API call. */ function useFake() { const timer = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined) React.useEffect(() => () => clearTimeout(timer.current), []) return (done: () => void, ms = 700) => { clearTimeout(timer.current) timer.current = setTimeout(done, ms) } } function Heading({ title, children }: { title: string; children?: React.ReactNode }) { return ( <header className="mb-7"> <h1 className="text-[1.75rem] leading-tight font-semibold tracking-[-0.03em] text-balance">{title}</h1> {children && <p className="text-muted-foreground mt-2 text-pretty">{children}</p>} </header> ) } function Success({ title, children, action }: { title: string; children: React.ReactNode; action?: React.ReactNode }) { return ( <div role="status"> <span className="bg-chart-2/15 text-chart-2 flex size-12 items-center justify-center rounded-2xl"><Check className="size-6" aria-hidden="true" /></span> <h1 className="mt-5 text-[1.75rem] leading-tight font-semibold tracking-[-0.03em]">{title}</h1> <p className="text-muted-foreground mt-2 text-pretty">{children}</p> {action && <div className="mt-6">{action}</div>} </div> ) } /** Sign in with email and password, or a provider. Validates before it submits. */ function SignInForm({ hrefs }: FormProps) { const links = h(hrefs) const fake = useFake() const [errors, setErrors] = React.useState<{ email?: string; password?: string }>({}) const [busy, setBusy] = React.useState(false) const [done, setDone] = React.useState(false) function submit(e: React.FormEvent<HTMLFormElement>) { e.preventDefault() const f = new FormData(e.currentTarget) const email = String(f.get("email") ?? "") const password = String(f.get("password") ?? "") const next: typeof errors = {} if (!emailOk(email)) next.email = "Enter an email like you@company.com." if (!password) next.password = "Enter your password." setErrors(next) if (next.email || next.password) { ;(e.currentTarget.elements.namedItem(next.email ? "email" : "password") as HTMLElement | null)?.focus() return } setBusy(true) fake(() => { setBusy(false); setDone(true) }) } if (done) return <Success title="You’re in" action={<a className={link} href="#">Continue to your workspace →</a>}>Welcome back. We’ll take you to where you left off.</Success> return ( <> <Heading title="Welcome back">Sign in to continue to your workspace.</Heading> <div className="grid grid-cols-2 gap-3"> {["Google", "GitHub"].map((p) => <button key={p} type="button" className="hover:bg-accent focus-visible:ring-ring/50 h-11 rounded-xl border text-sm font-medium outline-none transition-colors focus-visible:ring-[3px]">{p}</button>)} </div> <div className="text-muted-foreground my-6 flex items-center gap-3 text-xs"><span className="bg-border h-px flex-1" />or with email<span className="bg-border h-px flex-1" /></div> <form onSubmit={submit} noValidate className="space-y-4"> <AuthField id="si-email" name="email" type="email" label="Email" autoComplete="email" error={errors.email} /> <div> <AuthPassword id="si-pass" name="password" autoComplete="current-password" error={errors.password} /> <div className="mt-2.5 flex items-center justify-between text-sm"> <label className="flex cursor-pointer items-center gap-2"><input type="checkbox" name="remember" className="accent-primary size-4" />Keep me signed in</label> <a href={links.forgot} className={link}>Forgot password?</a> </div> </div> <AuthButton type="submit" busy={busy}>{busy ? "Signing in…" : "Sign in"}</AuthButton> </form> <p className="text-muted-foreground mt-6 text-center text-sm">New here? <a href={links["sign-up"]} className={link}>Create an account</a></p> </> ) } /** Create an account with a live password checklist and a terms checkbox. */ function SignUpForm({ hrefs }: FormProps) { const links = h(hrefs) const fake = useFake() const [pw, setPw] = React.useState("") const [errors, setErrors] = React.useState<{ name?: string; email?: string; password?: string; terms?: string }>({}) const [busy, setBusy] = React.useState(false) const [email, setEmail] = React.useState<string | null>(null) function submit(e: React.FormEvent<HTMLFormElement>) { e.preventDefault() const f = new FormData(e.currentTarget) const name = String(f.get("name") ?? "").trim() const mail = String(f.get("email") ?? "").trim() const next: typeof errors = {} if (!name) next.name = "Tell us your name." if (!emailOk(mail)) next.email = "Enter an email like you@company.com." if (!passwordRules.every((r) => r.test(pw))) next.password = "Meet every rule below to continue." if (!f.get("terms")) next.terms = "Agree to the terms to continue." setErrors(next) const first = (["name", "email", "password", "terms"] as const).find((k) => next[k]) if (first) { ;(e.currentTarget.elements.namedItem(first) as HTMLElement | null)?.focus() return } setBusy(true) fake(() => { setBusy(false); setEmail(mail) }) } if (email) return <Success title="Check your email" action={<a className={link} href={links.verify}>Enter the 6-digit code →</a>}>We sent a code to <span className="text-foreground font-medium">{email}</span>. It expires in 10 minutes.</Success> return ( <> <Heading title="Create your account">Free for 14 days. No card needed.</Heading> <form onSubmit={submit} noValidate className="space-y-4"> <AuthField id="su-name" name="name" label="Full name" autoComplete="name" error={errors.name} /> <AuthField id="su-email" name="email" type="email" label="Work email" autoComplete="email" error={errors.email} /> <div> <AuthPassword id="su-pass" name="password" label="Password" autoComplete="new-password" value={pw} onChange={(e) => setPw(e.target.value)} error={errors.password} /> <PasswordStrength id="su-strength" value={pw} /> </div> <div> <label className="flex cursor-pointer items-start gap-2.5 text-sm"><input type="checkbox" name="terms" aria-invalid={!!errors.terms} className="accent-primary mt-0.5 size-4" /><span>I agree to the <a href="#" className={link}>Terms</a> and <a href="#" className={link}>Privacy Policy</a>.</span></label> {errors.terms && <p className="text-destructive mt-1.5 text-sm">{errors.terms}</p>} </div> <AuthButton type="submit" busy={busy}>{busy ? "Creating account…" : "Create account"}</AuthButton> </form> <p className="text-muted-foreground mt-6 text-center text-sm">Already have an account? <a href={links["sign-in"]} className={link}>Sign in</a></p> </> ) } /** Verify with a 6-digit code: auto-submits when complete, and offers a resend after a countdown. */ function VerifyForm({ hrefs }: FormProps) { const links = h(hrefs) const [code, setCode] = React.useState("") const [state, setState] = React.useState<"idle" | "error" | "ok">("idle") const [wait, setWait] = React.useState(30) React.useEffect(() => { if (wait <= 0) return const id = setTimeout(() => setWait((w) => w - 1), 1000) return () => clearTimeout(id) }, [wait]) if (state === "ok") return <Success title="Email verified" action={<a className={link} href={links.onboarding}>Set up your workspace →</a>}>Thanks. Your account is ready.</Success> return ( <> <span className="bg-primary/10 text-primary mb-5 flex size-12 items-center justify-center rounded-2xl"><MailCheck className="size-6" aria-hidden="true" /></span> <Heading title="Enter your code">We sent a 6-digit code to <span className="text-foreground font-medium">mina@fieldnote.example</span>. Try 123456.</Heading> <label htmlFor="vf-code" className="sr-only">6-digit code</label> <InputOTP id="vf-code" maxLength={6} value={code} aria-invalid={state === "error" || undefined} aria-describedby="vf-status" pattern="^[0-9]*$" onChange={(v) => { setCode(v); setState("idle") }} onComplete={(v) => setState(v === "123456" ? "ok" : "error")} containerClassName="justify-center"> <InputOTPGroup>{[0, 1, 2].map((i) => <InputOTPSlot key={i} index={i} invalid={state === "error"} className="size-12 text-lg" />)}</InputOTPGroup> <InputOTPSeparator /> <InputOTPGroup>{[3, 4, 5].map((i) => <InputOTPSlot key={i} index={i} invalid={state === "error"} className="size-12 text-lg" />)}</InputOTPGroup> </InputOTP> <p id="vf-status" role="status" className={cn("mt-4 text-center text-sm", state === "error" ? "text-destructive" : "text-muted-foreground")}>{state === "error" ? "That code isn’t right. Check it and try again." : "Paste the code, or type it in."}</p> <p className="text-muted-foreground mt-6 text-center text-sm"> Didn’t get it?{" "} <button type="button" disabled={wait > 0} onClick={() => { setWait(30); setCode(""); setState("idle") }} className={cn(link, "disabled:text-muted-foreground disabled:no-underline")}>{wait > 0 ? `Resend in ${wait}s` : "Resend the code"}</button> </p> <p className="mt-3 text-center text-sm"><a href={links["sign-in"]} className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1.5"><ArrowLeft className="size-3.5 rtl:rotate-180" aria-hidden="true" />Back to sign in</a></p> </> ) } /** Ask for a reset link. */ function ForgotForm({ hrefs }: FormProps) { const links = h(hrefs) const fake = useFake() const [error, setError] = React.useState("") const [busy, setBusy] = React.useState(false) const [sent, setSent] = React.useState<string | null>(null) function submit(e: React.FormEvent<HTMLFormElement>) { e.preventDefault() const mail = String(new FormData(e.currentTarget).get("email") ?? "").trim() if (!emailOk(mail)) { setError("Enter an email like you@company.com.") ;(e.currentTarget.elements.namedItem("email") as HTMLElement | null)?.focus() return } setError("") setBusy(true) fake(() => { setBusy(false); setSent(mail) }) } if (sent) return <Success title="Check your email" action={<button type="button" onClick={() => setSent(null)} className={link}>Use a different email</button>}>If an account exists for <span className="text-foreground font-medium">{sent}</span>, a reset link is on its way. It works for one hour.</Success> return ( <> <Heading title="Forgot your password?">No problem. Tell us your email and we’ll send a link to choose a new one.</Heading> <form onSubmit={submit} noValidate className="space-y-4"> <AuthField id="fp-email" name="email" type="email" label="Email" autoComplete="email" error={error} /> <AuthButton type="submit" busy={busy}>{busy ? "Sending…" : "Send reset link"}</AuthButton> </form> <p className="mt-6 text-center text-sm"><a href={links["sign-in"]} className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1.5"><ArrowLeft className="size-3.5 rtl:rotate-180" aria-hidden="true" />Back to sign in</a></p> </> ) } /** Choose a new password, with confirmation. */ function ResetForm({ hrefs }: FormProps) { const links = h(hrefs) const fake = useFake() const [pw, setPw] = React.useState("") const [errors, setErrors] = React.useState<{ password?: string; confirm?: string }>({}) const [busy, setBusy] = React.useState(false) const [done, setDone] = React.useState(false) function submit(e: React.FormEvent<HTMLFormElement>) { e.preventDefault() const confirm = String(new FormData(e.currentTarget).get("confirm") ?? "") const next: typeof errors = {} if (!passwordRules.every((r) => r.test(pw))) next.password = "Meet every rule below to continue." else if (confirm !== pw) next.confirm = "The passwords don’t match." setErrors(next) if (next.password || next.confirm) { ;(e.currentTarget.elements.namedItem(next.password ? "password" : "confirm") as HTMLElement | null)?.focus() return } setBusy(true) fake(() => { setBusy(false); setDone(true) }) } if (done) return <Success title="Password updated" action={<a className={link} href={links["sign-in"]}>Sign in with your new password →</a>}>You’ve been signed out everywhere else for your safety.</Success> return ( <> <Heading title="Choose a new password">Pick something you haven’t used before.</Heading> <form onSubmit={submit} noValidate className="space-y-4"> <div> <AuthPassword id="rs-pass" name="password" label="New password" autoComplete="new-password" value={pw} onChange={(e) => setPw(e.target.value)} error={errors.password} /> <PasswordStrength id="rs-strength" value={pw} /> </div> <AuthPassword id="rs-confirm" name="confirm" label="Confirm password" autoComplete="new-password" error={errors.confirm} /> <AuthButton type="submit" busy={busy}>{busy ? "Saving…" : "Update password"}</AuthButton> </form> </> ) } /** Accept an invitation to a team. */ function InviteForm({ hrefs }: FormProps) { const fake = useFake() const [pw, setPw] = React.useState("") const [error, setError] = React.useState("") const [busy, setBusy] = React.useState(false) const [done, setDone] = React.useState(false) function submit(e: React.FormEvent<HTMLFormElement>) { e.preventDefault() if (!passwordRules.every((r) => r.test(pw))) { setError("Meet every rule below to continue.") ;(e.currentTarget.elements.namedItem("password") as HTMLElement | null)?.focus() return } setError("") setBusy(true) fake(() => { setBusy(false); setDone(true) }) } const links = h(hrefs) if (done) return <Success title="Welcome to Fieldnote Goods" action={<a className={link} href={links.onboarding}>Finish setting up →</a>}>You’ve joined as an editor.</Success> return ( <> <div className="bg-secondary mb-6 flex items-center gap-3 rounded-2xl p-3.5"> <span className="bg-chart-4/25 flex size-11 items-center justify-center rounded-xl text-sm font-semibold" aria-hidden="true">FG</span> <p className="text-sm text-pretty"><span className="font-semibold">Maya Okafor</span> invited you to join <span className="font-semibold">Fieldnote Goods</span> as an editor.</p> </div> <Heading title="Accept your invitation">Create a password for <span className="text-foreground font-medium">dev@fieldnote.example</span>.</Heading> <form onSubmit={submit} noValidate className="space-y-4"> <AuthField id="iv-name" name="name" label="Full name" defaultValue="Dev Patel" autoComplete="name" /> <div> <AuthPassword id="iv-pass" name="password" autoComplete="new-password" value={pw} onChange={(e) => setPw(e.target.value)} error={error} /> <PasswordStrength id="iv-strength" value={pw} /> </div> <AuthButton type="submit" busy={busy}>{busy ? "Joining…" : "Join Fieldnote Goods"}</AuthButton> </form> </> ) } const steps = ["You", "Workspace", "Team"] /** Three short steps to set up a workspace, with progress and a slug that follows the name. */ function OnboardingForm({ hrefs }: FormProps) { const links = h(hrefs) const [step, setStep] = React.useState(0) const [name, setName] = React.useState("Mina Kovac") const [role, setRole] = React.useState("Design") const [ws, setWs] = React.useState("Fieldnote Goods") const [invites, setInvites] = React.useState("") const [error, setError] = React.useState("") const [done, setDone] = React.useState(false) const head = React.useRef<HTMLHeadingElement>(null) const slug = ws.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") const emails = invites.split(/[\s,]+/).filter(Boolean) const roles = ["Design", "Engineering", "Product", "Marketing", "Operations"] function next() { if (step === 0 && !name.trim()) return setError("Tell us your name.") if (step === 1 && !ws.trim()) return setError("Give your workspace a name.") if (step === 2 && emails.some((m) => !emailOk(m))) return setError("One of those doesn’t look like an email address.") setError("") if (step === 2) return setDone(true) setStep((s) => s + 1) requestAnimationFrame(() => head.current?.focus()) } if (done) return <Success title="You’re all set" action={<a className={link} href="#">Open {ws || "your workspace"} →</a>}>{emails.length > 0 ? `We sent ${emails.length} invitation${emails.length === 1 ? "" : "s"}.` : "You can invite your team any time."}</Success> return ( <> <ol className="mb-7 flex items-center gap-2" aria-label="Setup progress"> {steps.map((s, i) => ( <li key={s} aria-current={i === step ? "step" : undefined} className="flex flex-1 items-center gap-2 text-xs font-medium"> <span className={cn("flex size-6 shrink-0 items-center justify-center rounded-full border text-[11px]", i < step ? "bg-primary text-primary-foreground border-transparent" : i === step ? "border-primary text-primary" : "text-muted-foreground")}>{i < step ? <Check className="size-3.5" aria-label="done" /> : i + 1}</span> <span className={i === step ? "text-foreground" : "text-muted-foreground"}>{s}</span> {i < steps.length - 1 && <span className="bg-border h-px flex-1" aria-hidden="true" />} </li> ))} </ol> <header className="mb-6"> <h1 ref={head} tabIndex={-1} className="text-[1.75rem] leading-tight font-semibold tracking-[-0.03em] outline-none">{step === 0 ? "Tell us about you" : step === 1 ? "Name your workspace" : "Invite your team"}</h1> <p className="text-muted-foreground mt-2 text-pretty">{step === 0 ? "So your teammates know who you are." : step === 1 ? "You can change this any time." : "They’ll get an email with a link to join. You can skip this."}</p> </header> <div className="space-y-4"> {step === 0 && ( <> <AuthField id="ob-name" label="Full name" value={name} onChange={(e) => setName(e.target.value)} autoComplete="name" error={error} /> <fieldset><legend className="text-sm font-medium">Your team</legend> <div className="mt-2 flex flex-wrap gap-2"> {roles.map((r) => <label key={r} className={cn("focus-within:ring-ring/50 cursor-pointer rounded-full border px-3.5 py-1.5 text-sm transition-colors focus-within:ring-[3px]", role === r ? "bg-primary text-primary-foreground border-transparent" : "hover:bg-accent")}><input type="radio" name="role" value={r} checked={role === r} onChange={() => setRole(r)} className="sr-only" />{r}</label>)} </div> </fieldset> </> )} {step === 1 && ( <> <AuthField id="ob-ws" label="Workspace name" value={ws} onChange={(e) => setWs(e.target.value)} error={error} hint={slug ? `Your address: ${slug}.keystone.app` : undefined} /> </> )} {step === 2 && ( <div> <label htmlFor="ob-inv" className="text-sm font-medium">Email addresses</label> <textarea id="ob-inv" rows={3} value={invites} onChange={(e) => setInvites(e.target.value)} placeholder="dev@company.com, priya@company.com" aria-invalid={!!error} className="bg-background focus-visible:ring-ring/50 placeholder:text-muted-foreground mt-1.5 w-full resize-none rounded-xl border p-3.5 text-[15px] outline-none focus-visible:ring-[3px] aria-[invalid=true]:border-destructive" /> <p className={cn("mt-1.5 text-sm", error ? "text-destructive" : "text-muted-foreground")} role="status">{error || (emails.length ? `${emails.length} ${emails.length === 1 ? "person" : "people"} will be invited` : "Separate addresses with commas or spaces.")}</p> </div> )} </div> <div className="mt-7 flex items-center justify-between gap-3"> {step > 0 ? <button type="button" onClick={() => { setError(""); setStep((s) => s - 1) }} className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex items-center gap-1.5 rounded-lg px-1 text-sm outline-none focus-visible:ring-[3px]"><ArrowLeft className="size-4 rtl:rotate-180" aria-hidden="true" />Back</button> : <button type="button" onClick={() => setDone(true)} className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 rounded px-1 text-sm outline-none focus-visible:ring-[3px]">Skip for now</button>} <AuthButton type="button" onClick={next} className="w-auto px-6">{step === 2 ? (emails.length ? "Send invitations" : "Finish") : "Continue"}{step < 2 && <ArrowRight className="size-4 rtl:rotate-180" aria-hidden="true" />}</AuthButton> </div> <p className="text-muted-foreground mt-6 flex items-center justify-center gap-1.5 text-xs"><ShieldCheck className="size-3.5" aria-hidden="true" />Step {step + 1} of {steps.length}</p> </> ) } export { ForgotForm, InviteForm, OnboardingForm, ResetForm, SignInForm, SignUpForm, VerifyForm, type FormProps }components/ballmac/templates/auth-kit/auth-kit-pages.tsx// Ballmac UI: Auth Kit pages. https://ui.ballmac.com/templates/template-auth-kit "use client" import * as React from "react" import { SegmentedControl, SegmentedControlItem } from "@/components/ballmac/segmented-control" import { ForgotForm, InviteForm, OnboardingForm, ResetForm, SignInForm, SignUpForm, VerifyForm } from "@/components/ballmac/templates/auth-kit/auth-kit-forms" import { AuthFrame, authDefaultHrefs, type AuthHrefs, type AuthLayout, type AuthPage } from "@/components/ballmac/templates/auth-kit/auth-kit-theme" import { cn } from "@/lib/utils" const forms: Record<AuthPage, React.ComponentType<{ hrefs?: Partial<AuthHrefs> }>> = { "sign-in": SignInForm, "sign-up": SignUpForm, verify: VerifyForm, forgot: ForgotForm, reset: ResetForm, invite: InviteForm, onboarding: OnboardingForm, } const labels: Record<AuthPage, string> = { "sign-in": "Sign in", "sign-up": "Sign up", verify: "Verify", forgot: "Forgot", reset: "Reset", invite: "Invite", onboarding: "Onboarding" } const defaultLayout: Record<AuthPage, AuthLayout> = { "sign-in": "split", "sign-up": "centered", verify: "glass", forgot: "centered", reset: "centered", invite: "split", onboarding: "glass" } type AuthKitPageProps = React.ComponentProps<"div"> & { /** Which page to show. */ page: AuthPage /** How the page is composed. Defaults to the layout that suits the page. */ layout?: AuthLayout /** Override where pages live (used by previews). */ hrefs?: Partial<AuthHrefs> } /** One page of the auth flow in a chosen layout. */ function AuthKitPage({ page, layout, hrefs, ...props }: AuthKitPageProps) { const Form = forms[page] return ( <AuthFrame layout={layout ?? defaultLayout[page]} {...props}> <Form hrefs={hrefs} /> </AuthFrame> ) } type AuthKitPlaygroundProps = React.ComponentProps<"div"> & { /** Page shown first. */ defaultPage?: AuthPage /** Layout shown first. */ defaultLayout?: AuthLayout } /** Every page and every layout in one place: pick a flow and a layout from the bar and see the result. */ function AuthKitPlayground({ defaultPage = "sign-in", defaultLayout: initialLayout = "split", ...props }: AuthKitPlaygroundProps) { const [page, setPage] = React.useState<AuthPage>(defaultPage) const [layout, setLayout] = React.useState<AuthLayout>(initialLayout) const Form = forms[page] // Links between forms switch the page here instead of navigating away. const hrefs = Object.fromEntries((Object.keys(forms) as AuthPage[]).map((k) => [k, `#${k}`])) as AuthHrefs React.useEffect(() => { const onHash = () => { const k = window.location.hash.slice(1) as AuthPage if (k in forms) setPage(k) } window.addEventListener("hashchange", onHash) return () => window.removeEventListener("hashchange", onHash) }, []) return ( <AuthFrame layout={layout} {...props}> <Form key={page} hrefs={hrefs} /> <div role="region" aria-label="Auth Kit controls" className="bg-popover/90 fixed inset-x-3 top-3 z-50 mx-auto flex max-w-3xl flex-col gap-3 rounded-2xl border p-3 shadow-[0_20px_50px_-12px_rgb(0_0_0/0.35)] backdrop-blur-xl sm:flex-row sm:items-center"> <div role="group" aria-label="Page" className="flex gap-1 overflow-x-auto [scrollbar-width:none]"> {(Object.keys(forms) as AuthPage[]).map((k) => ( <button key={k} type="button" aria-pressed={page === k} onClick={() => setPage(k)} className={cn("focus-visible:ring-ring/50 h-8 shrink-0 rounded-lg px-3 text-[13px] font-medium outline-none transition-colors focus-visible:ring-[3px]", page === k ? "bg-primary text-primary-foreground" : "hover:bg-accent text-muted-foreground")}>{labels[k]}</button> ))} </div> <SegmentedControl aria-label="Layout" value={layout} onValueChange={(v) => setLayout(v as AuthLayout)} size="sm" className="sm:ms-auto"> <SegmentedControlItem value="centered">Centered</SegmentedControlItem> <SegmentedControlItem value="split">Split</SegmentedControlItem> <SegmentedControlItem value="glass">Glass</SegmentedControlItem> </SegmentedControl> </div> </AuthFrame> ) } export { AuthKitPage, AuthKitPlayground, authDefaultHrefs, type AuthKitPageProps, type AuthKitPlaygroundProps }app/auth/sign-in/page.tsx// Ballmac UI: Auth Kit template route. https://ui.ballmac.com/templates/template-auth-kit import type { Metadata } from "next" import { AuthKitPage } from "@/components/ballmac/templates/auth-kit/auth-kit-pages" export const metadata: Metadata = { title: "Sign in", description: "Welcome back. Sign in to continue.", } export default function Page() { return <AuthKitPage page="sign-in" /> }app/auth/sign-up/page.tsx// Ballmac UI: Auth Kit template route. https://ui.ballmac.com/templates/template-auth-kit import type { Metadata } from "next" import { AuthKitPage } from "@/components/ballmac/templates/auth-kit/auth-kit-pages" export const metadata: Metadata = { title: "Create your account", description: "Free for 14 days. No card needed.", } export default function Page() { return <AuthKitPage page="sign-up" /> }app/auth/verify/page.tsx// Ballmac UI: Auth Kit template route. https://ui.ballmac.com/templates/template-auth-kit import type { Metadata } from "next" import { AuthKitPage } from "@/components/ballmac/templates/auth-kit/auth-kit-pages" export const metadata: Metadata = { title: "Verify your email", description: "Enter the 6-digit code we sent you.", } export default function Page() { return <AuthKitPage page="verify" /> }app/auth/forgot/page.tsx// Ballmac UI: Auth Kit template route. https://ui.ballmac.com/templates/template-auth-kit import type { Metadata } from "next" import { AuthKitPage } from "@/components/ballmac/templates/auth-kit/auth-kit-pages" export const metadata: Metadata = { title: "Forgot your password?", description: "Get a link to choose a new password.", } export default function Page() { return <AuthKitPage page="forgot" /> }app/auth/reset/page.tsx// Ballmac UI: Auth Kit template route. https://ui.ballmac.com/templates/template-auth-kit import type { Metadata } from "next" import { AuthKitPage } from "@/components/ballmac/templates/auth-kit/auth-kit-pages" export const metadata: Metadata = { title: "Choose a new password", description: "Pick a password you haven't used before.", } export default function Page() { return <AuthKitPage page="reset" /> }app/auth/invite/page.tsx// Ballmac UI: Auth Kit template route. https://ui.ballmac.com/templates/template-auth-kit import type { Metadata } from "next" import { AuthKitPage } from "@/components/ballmac/templates/auth-kit/auth-kit-pages" export const metadata: Metadata = { title: "Accept your invitation", description: "Join your team's workspace.", } export default function Page() { return <AuthKitPage page="invite" /> }app/auth/onboarding/page.tsx// Ballmac UI: Auth Kit template route. https://ui.ballmac.com/templates/template-auth-kit import type { Metadata } from "next" import { AuthKitPage } from "@/components/ballmac/templates/auth-kit/auth-kit-pages" export const metadata: Metadata = { title: "Set up your workspace", description: "Three short steps to get started.", } export default function Page() { return <AuthKitPage page="onboarding" /> }Update the import paths to match your project setup.
The page is added as a route in your app, with every block it uses.
What's inside
- Pages
- Playground
- Sign in/auth/sign-in
- Sign up/auth/sign-up
- Verify/auth/verify
- Forgot/auth/forgot
- Reset/auth/reset
- Invite/auth/invite
- Onboarding/auth/onboarding
- Fonts
- Outfit (Google Fonts, loaded with next/font)
- Files
- 11 files · 3 Ballmac blocks and components
Built with
Dependencies
- npm
- lucide-react
Use with AI
Installs a complete authentication flow. AuthKitPage renders one page in a layout (centered, split or glass); AuthKitPlayground lets you try every combination. Replace the simulated requests in auth-kit-forms.tsx with your auth provider. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Auth Kit: complete sign-in flow (@ballmac/template-auth-kit) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Any product that needs sign in, sign up, verification and onboarding that look consistent
- Trying layouts quickly before you commit to one
Not for
- A single login box (use login-1 or login-2)
Registry JSON: https://ui.ballmac.com/r/template-auth-kit.json
Free to use in personal and commercial projects.