Sign in and account
Centered and split layouts for sign in, sign up, verification and account states.
What it's for#
Everything before someone is inside a workspace: logging in, signing up, proving it's them, and the account states that stop them at the door. These pages sit outside the shell, with no sidebar and no app header.
There are two layouts. Log in and sign up are split: the form on the left, a sample of the product on the right from 1024px. Everything else is centered: one narrow column on the plane, with no card around it. Both keep the same 24rem form column, the wordmark top left and a quiet footer, so moving from log in to verification feels like one flow.
| Route | Layout | What it asks |
|---|---|---|
| /login | Split | Providers, a passkey, or email and password. |
| /signup | Split | Name, work email, password and the terms. |
| /verify-otp | Centered | The 6-digit code we emailed. |
| /verify | Centered | Two-factor: an authenticator code, a text or a backup code. |
| /paused | Centered | A workspace paused for billing, outside any workspace. |
| /suspended | Centered | Your membership in a workspace is suspended. |
Live composition#
The two layouts at 1280px, built from Canon fields and buttons.
Split: log in
Try logging in with an empty form to see field errors, or pick a provider to see the pending state.
import { Field, FieldError, FieldGroup, FieldLabel, FieldSeparator } from "@oration/canon/components/field";import { Input } from "@oration/canon/components/input";import { MonogramTile } from "@oration/canon/components/monogram-tile";import { PasswordInput } from "@oration/canon/components/password-input";import { PendingButton } from "@oration/canon/components/pending-button";import { toast } from "@oration/canon/components/toast";import { FingerprintIcon } from "lucide-react";import * as React from "react";import { AuthHeading, AuthSplit, ProductPanel } from "./chrome";export function LogIn() { const [email, setEmail] = React.useState(""); const [password, setPassword] = React.useState(""); const [errors, setErrors] = React.useState<{ email?: string; password?: string; }>({}); const [pending, setPending] = React.useState<string | null>(null); const linkClass = "font-medium text-foreground underline-offset-4 hover:underline"; const mutedLinkClass = "text-muted-foreground transition-colors duration-150 hover:text-foreground"; const continueWith = (label: string) => { setPending(label); window.setTimeout(() => { setPending(null); toast.add({ title: `Continuing with ${label}`, description: "Opening Cedarline.", }); }, 900); }; return ( <AuthSplit panel={ <ProductPanel caller="Jordan Lee" company="Northwind Freight" lines={[ { speaker: "Jordan Lee", text: "I'm calling about invoice NF-4471. It says on hold.", }, { speaker: "Nora", text: "It's waiting on a matching PO. I can see PO 88120 on your account. Should I attach it?", }, { speaker: "Jordan Lee", text: "Yes, please. That's the right one.", }, ]} outcome="Resolved without a transfer. NF-4471 joins Friday's payment run." /> } > <AuthHeading title="Log in to Oration" /> <div className="mt-8 flex flex-col gap-2"> {[ ["Google", "blue"], ["Microsoft", "teal"], ].map(([name, color]) => ( <PendingButton key={name} variant="outline" size="lg" className="w-full" pending={pending === name} onClick={() => continueWith(String(name))} > <MonogramTile name={String(name)} color={color === "blue" ? "blue" : "teal"} size="sm" /> Continue with {name} </PendingButton> ))} <PendingButton variant="outline" size="lg" className="w-full" pending={pending === "a passkey"} onClick={() => continueWith("a passkey")} > <FingerprintIcon data-icon="inline-start" aria-hidden="true" /> Log in with a passkey </PendingButton> </div> <FieldSeparator className="my-6">or</FieldSeparator> <form noValidate onSubmit={(event) => { event.preventDefault(); const next = { email: email.includes("@") ? undefined : "Enter the email you log in with.", password: password ? undefined : "Enter your password.", }; setErrors(next); if (next.email || next.password) return; continueWith("your password"); }} > <FieldGroup className="gap-4"> <Field data-invalid={errors.email ? true : undefined}> <FieldLabel htmlFor="login-email">Email</FieldLabel> <Input id="login-email" type="email" autoComplete="username webauthn" placeholder="name@company.com" className="h-9" value={email} aria-invalid={errors.email ? true : undefined} aria-describedby={ errors.email ? "login-email-error" : undefined } onChange={(event) => setEmail(event.target.value)} /> <FieldError id="login-email-error"> {errors.email} </FieldError> </Field> <Field data-invalid={errors.password ? true : undefined} className="relative" > <FieldLabel htmlFor="login-password"> Password </FieldLabel> <PasswordInput id="login-password" autoComplete="current-password" value={password} aria-invalid={errors.password ? true : undefined} aria-describedby={ errors.password ? "login-password-error" : undefined } onChange={(event) => setPassword(event.target.value) } /> <FieldError id="login-password-error"> {errors.password} </FieldError> <button type="button" onClick={() => toast.add({ title: "Forgot password?", description: "We emailed a reset link to the address above.", }) } className={`absolute top-0 right-0 w-auto! text-13 leading-snug ${mutedLinkClass}`} > Forgot password? </button> </Field> <PendingButton type="submit" size="lg" className="mt-1 w-full" pending={pending === "your password"} > Log in </PendingButton> </FieldGroup> </form> <p className="mt-4 text-center text-sm"> <button type="button" onClick={() => toast.add({ title: "Use single sign-on", description: "Enter your work email to find your organization.", }) } className={mutedLinkClass} > Use single sign-on </button> </p> <p className="mt-10 text-center text-sm text-muted-foreground"> New to Oration?{" "} <button type="button" onClick={() => toast.add({ title: "Create an account", description: "Sign up opens in the split layout.", }) } className={linkClass} > Create an account </button> </p> </AuthSplit> );}Centered: verify email
Type 000000 for a wrong code, 111111 for an expired one, or any other six digits to verify. The resend button counts down from 0:30.
import { Button } from "@oration/canon/components/button";import { Field, FieldError, FieldLabel } from "@oration/canon/components/field";import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from "@oration/canon/components/input-otp";import { toast } from "@oration/canon/components/toast";import { cn } from "@oration/canon/lib/utils";import { CheckIcon, MailIcon, TimerOffIcon } from "lucide-react";import * as React from "react";import { AuthCentered, AuthHeading, AuthIconTile } from "./chrome";export function VerifyEmail() { const [code, setCode] = React.useState(""); const [error, setError] = React.useState<string | null>(null); const [expired, setExpired] = React.useState(false); const [verified, setVerified] = React.useState(false); const [cooldown, setCooldown] = React.useState(30); React.useEffect(() => { if (cooldown <= 0) return; const id = window.setTimeout(() => setCooldown(cooldown - 1), 1000); return () => window.clearTimeout(id); }, [cooldown]); const verify = (value: string) => { if (value.length < 6) return setError("Enter all 6 digits of the code."); if (value === "000000") return setError( "That code isn't right. Check the latest email and try again.", ); if (value === "111111") return setExpired(true); setError(null); setVerified(true); toast.add({ type: "success", title: "Email verified", description: "Opening workspace setup.", }); }; return ( <AuthCentered> <AuthHeading media={ <AuthIconTile tone={verified ? "success" : "neutral"}> {verified ? <CheckIcon /> : <MailIcon />} </AuthIconTile> } title="Enter the code we emailed you" description={ <> We sent a 6-digit code to{" "} <span className="font-medium text-foreground"> maya@cedarline.com </span> . It expires in 10 minutes. </> } /> <form noValidate className="mt-8 flex flex-col gap-5" onSubmit={(event) => { event.preventDefault(); verify(code); }} > {expired ? ( <p className="flex items-start gap-3 rounded-lg bg-muted/70 px-3 py-2.5 text-13 text-muted-foreground"> <TimerOffIcon aria-hidden="true" className="mt-0.5 size-4 shrink-0" /> That code expired. Codes work for 10 minutes. </p> ) : null} <Field data-invalid={error ? true : undefined}> <FieldLabel htmlFor="verify-code" className="sr-only"> Code from email </FieldLabel> <InputOTP id="verify-code" maxLength={6} pattern="^\d+$" inputMode="numeric" autoComplete="one-time-code" value={code} onChange={(value) => { setCode(value); if (error) setError(null); }} onComplete={verify} aria-invalid={error ? true : undefined} aria-describedby={ error ? "verify-code-error" : undefined } containerClassName="w-full gap-2" > {[0, 3].map((start) => ( <React.Fragment key={start}> {start > 0 ? ( <InputOTPSeparator className="text-subtle-foreground" /> ) : null} <InputOTPGroup className="flex-1"> {[start, start + 1, start + 2].map( (index) => ( <InputOTPSlot key={index} index={index} aria-invalid={ error ? true : undefined } className={cn( "h-12 flex-1 text-lg font-medium tabular-nums", error && "border-destructive data-[active=true]:border-destructive data-[active=true]:ring-destructive/20", verified && "border-success/60", )} /> ), )} </InputOTPGroup> </React.Fragment> ))} </InputOTP> <FieldError id="verify-code-error">{error}</FieldError> </Field> <Button type="submit" size="lg" className="w-full"> {verified ? ( <CheckIcon data-icon="inline-start" aria-hidden="true" /> ) : null} {verified ? "Verified" : expired ? "Send a new code" : "Verify email"} </Button> </form> <div className="mt-8 flex flex-col gap-2 border-t border-border pt-6 text-sm"> <div className="flex min-h-8 items-center justify-between gap-3"> <span className="text-muted-foreground"> Didn't get the code? </span> <Button variant="outline" size="sm" disabled={cooldown > 0} className="disabled:opacity-60" onClick={() => { setCooldown(30); setExpired(false); toast.add({ title: "New code sent", description: "Earlier codes no longer work.", }); }} > Send a new code {cooldown > 0 ? ( <span className="text-muted-foreground tabular-nums"> 0:{String(cooldown).padStart(2, "0")} </span> ) : null} </Button> </div> <div className="flex min-h-8 items-center justify-between gap-3"> <span className="text-muted-foreground"> Wrong address? </span> <Button variant="ghost" size="sm"> Use a different email </Button> </div> </div> </AuthCentered> );}The wordmark, footer, product panel and trust row in these frames are Canon copies of the product's auth chrome, kept in this page's chrome.tsx. The product's panel plays a recorded call with Nora; here it is a still.
Anatomy#
Nine parts in the split layout. The centered layout keeps one through seven and drops the panel.
- Wordmark. Top left in a 64px header, linking to log in. Signed-in status pages add Logged in as and Log out on the right.
- Heading. The page's
h1in Headline, an optional 14px description, and on status pages a 40px icon tile or a workspace chip above it. - Providers. Continue with Google, Continue with Microsoft and Log in with a passkey: 36px outline buttons, full width, 8px apart.
- Separator. Field's
FieldSeparatorwith or, 24px above and below. - Fields. 36px inputs in a
FieldGroup16px apart, labels above, errors below, Forgot password? top right of the password field. - Primary button. One filled, full-width, 36px Pending button. It keeps its width and shows a spinner while working.
- Footer. Terms and Privacy as extra-small ghost buttons and a theme switch, in a 56px row.
- Product panel. From 1024px, an 8px inset Cool Rail panel with container corners and a sample supplier call on a card.
- Trust row. A shield icon and the plain names SOC 2, ISO 27001 and GDPR, then a Trust center link. Log in and sign up only.
The trust row#
Plain text, no badges or logos, and only where someone is deciding whether to hand over credentials: log in and sign up. Verification and status pages never show it.
- SOC 2
- ISO 27001
- GDPR
Screens#
Sign up, two-factor and the two account states, each on the layout it uses.
Sign up
Type a password to see the strength meter; a gmail.com address gets the work email hint.
import { Button } from "@oration/canon/components/button";import { Checkbox } from "@oration/canon/components/checkbox";import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel, FieldSeparator,} from "@oration/canon/components/field";import { Input } from "@oration/canon/components/input";import { Meter } from "@oration/canon/components/meter";import { MonogramTile } from "@oration/canon/components/monogram-tile";import { PasswordInput } from "@oration/canon/components/password-input";import { toast } from "@oration/canon/components/toast";import { cn } from "@oration/canon/lib/utils";import { CheckIcon } from "lucide-react";import * as React from "react";import { AuthHeading, AuthSplit, ProductPanel } from "./chrome";export function SignUp() { const [name, setName] = React.useState(""); const [email, setEmail] = React.useState(""); const [password, setPassword] = React.useState(""); const [terms, setTerms] = React.useState(false); const [attempted, setAttempted] = React.useState(false); const checks = [ { label: "8 or more characters", met: password.length >= 8 }, { label: "A number", met: /\d/.test(password) }, { label: "A symbol", met: /[^A-Za-z0-9]/.test(password) }, ]; const score = checks.filter((check) => check.met).length + (password.length >= 12 ? 1 : 0); const free = /@(gmail|yahoo|outlook|hotmail)\./i.test(email); return ( <AuthSplit panel={ <ProductPanel caller="Wen Zhou" company="Orchard Street" lines={[ { speaker: "Wen Zhou", text: "We're a new supplier. What do you need from us before the first payment?", }, { speaker: "Nora", text: "A W-9 and your bank details. I'll text you a secure link for both.", }, ]} outcome="W-9 requested. Wen gets a secure link by text." /> } > <AuthHeading title="Create your Oration account" description="Set up a workspace for your team in a few minutes." /> <div className="mt-8 flex flex-col gap-2"> <Button variant="outline" size="lg" className="w-full"> <MonogramTile name="Google" color="blue" size="sm" /> Sign up with Google </Button> <Button variant="outline" size="lg" className="w-full"> <MonogramTile name="Microsoft" color="teal" size="sm" /> Sign up with Microsoft </Button> </div> <FieldSeparator className="my-6">or</FieldSeparator> <form noValidate onSubmit={(event) => { event.preventDefault(); setAttempted(true); if (!name || !email || score < 3 || !terms) return; toast.add({ type: "success", title: "Account created", description: `We sent a 6-digit code to ${email}.`, }); }} > <FieldGroup className="gap-4"> <Field data-invalid={attempted && !name ? true : undefined}> <FieldLabel htmlFor="signup-name">Full name</FieldLabel> <Input id="signup-name" autoComplete="name" placeholder="Maya Okafor" className="h-9" value={name} onChange={(event) => setName(event.target.value)} /> <FieldError> {attempted && !name ? "Enter your full name." : null} </FieldError> </Field> <Field> <FieldLabel htmlFor="signup-email"> Work email </FieldLabel> <Input id="signup-email" type="email" autoComplete="email" placeholder="name@company.com" className="h-9" value={email} onChange={(event) => setEmail(event.target.value)} /> {free ? ( <FieldDescription className="text-13"> Use your work email so teammates can find your workspace. </FieldDescription> ) : null} </Field> <Field data-invalid={attempted && score < 3 ? true : undefined} > <FieldLabel htmlFor="signup-password"> Password </FieldLabel> <PasswordInput id="signup-password" autoComplete="new-password" value={password} onChange={(event) => setPassword(event.target.value) } /> {password ? ( <div className="flex flex-col gap-2"> <div className="flex items-center gap-3"> <Meter className="flex-1" size="sm" max={4} value={score} label="Password strength" tone={ score >= 3 ? "success" : score === 2 ? "warning" : "danger" } /> <span className="w-12 text-xs text-muted-foreground"> { [ "Weak", "Weak", "Fair", "Good", "Strong", ][score] } </span> </div> <ul className="flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground"> {checks.map((check) => ( <li key={check.label} className={cn( "flex items-center gap-1", check.met && "text-foreground", )} > <CheckIcon aria-hidden="true" className={cn( "size-3", !check.met && "opacity-30", )} /> {check.label} </li> ))} </ul> </div> ) : null} </Field> <Field data-invalid={attempted && !terms ? true : undefined} className="gap-1.5" > <FieldLabel className="items-start font-normal text-foreground"> <Checkbox className="mt-0.5" checked={terms} onCheckedChange={setTerms} /> I agree to the Oration terms of service and privacy policy </FieldLabel> <FieldError> {attempted && !terms ? "Accept the terms of service and privacy policy to continue." : null} </FieldError> </Field> <Button type="submit" size="lg" className="mt-1 w-full"> Create account </Button> </FieldGroup> </form> <p className="mt-10 text-center text-sm text-muted-foreground"> Already have an account?{" "} <button type="button" onClick={() => toast.add({ title: "Log in", description: "Log in opens in the split layout.", }) } className="font-medium text-foreground underline-offset-4 hover:underline" > Log in </button> </p> </AuthSplit> );}Two-factor
An authenticator code by default, with a backup code and a text as alternatives. 000000 fails.
import { Button } from "@oration/canon/components/button";import { Checkbox } from "@oration/canon/components/checkbox";import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@oration/canon/components/field";import { Input } from "@oration/canon/components/input";import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from "@oration/canon/components/input-otp";import { toast } from "@oration/canon/components/toast";import { KeyRoundIcon } from "lucide-react";import * as React from "react";import { AuthCentered, AuthHeading, AuthIconTile } from "./chrome";export function TwoFactor() { const [mode, setMode] = React.useState<"app" | "backup">("app"); const [code, setCode] = React.useState(""); const [backup, setBackup] = React.useState(""); const [trust, setTrust] = React.useState(true); const [error, setError] = React.useState<string | null>(null); const verify = () => { if (mode === "app" && code.length < 6) return setError("Enter all 6 digits of your code."); if (mode === "backup" && !/^[A-Z0-9]{4}-[A-Z0-9]{4}$/.test(backup)) return setError("Enter a backup code in the format XXXX-XXXX."); if (code === "000000") return setError( "That code didn't work. Check your authenticator app and try again.", ); setError(null); toast.add({ type: "success", title: "Verified", description: trust ? "This device is trusted for 60 days." : "Opening Cedarline.", }); }; return ( <AuthCentered> <AuthHeading media={ <AuthIconTile> <KeyRoundIcon /> </AuthIconTile> } title={ mode === "app" ? "Enter your verification code" : "Enter a backup code" } description={ mode === "app" ? "Open your authenticator app and enter the 6-digit code for Oration." : "Use one of the backup codes you saved when you turned on two-factor." } /> <form noValidate onSubmit={(event) => { event.preventDefault(); verify(); }} > <div className="mt-8"> {mode === "app" ? ( <Field data-invalid={error ? true : undefined}> <FieldLabel htmlFor="totp" className="sr-only"> Verification code </FieldLabel> <InputOTP id="totp" maxLength={6} pattern="^\d+$" inputMode="numeric" autoComplete="one-time-code" value={code} onChange={(value) => { setCode(value); if (error) setError(null); }} containerClassName="w-full gap-2" > {[0, 3].map((start) => ( <React.Fragment key={start}> {start > 0 ? ( <InputOTPSeparator className="text-subtle-foreground" /> ) : null} <InputOTPGroup className="flex-1"> {[start, start + 1, start + 2].map( (index) => ( <InputOTPSlot key={index} index={index} aria-invalid={ error ? true : undefined } className="h-12 flex-1 text-lg font-medium tabular-nums" /> ), )} </InputOTPGroup> </React.Fragment> ))} </InputOTP> <FieldError>{error}</FieldError> </Field> ) : ( <Field data-invalid={error ? true : undefined}> <FieldLabel htmlFor="backup-code"> Backup code </FieldLabel> <Input id="backup-code" maxLength={9} placeholder="XXXX-XXXX" className="h-9 font-mono tracking-widest" value={backup} onChange={(event) => { const raw = event.target.value .toUpperCase() .replace(/[^A-Z0-9]/g, ""); setBackup( raw.length > 4 ? `${raw.slice(0, 4)}-${raw.slice(4, 8)}` : raw, ); if (error) setError(null); }} /> <FieldDescription className="text-13"> Each backup code works once. </FieldDescription> <FieldError>{error}</FieldError> </Field> )} </div> <FieldGroup className="mt-5 gap-5"> <FieldLabel className="font-normal text-foreground"> <Checkbox checked={trust} onCheckedChange={setTrust} /> Trust this device for 60 days </FieldLabel> <Button type="submit" size="lg" className="w-full"> Verify </Button> </FieldGroup> </form> <div className="mt-4 flex flex-col items-center gap-2 text-sm"> <button type="button" className="text-muted-foreground transition-colors duration-150 hover:text-foreground" onClick={() => { setMode(mode === "app" ? "backup" : "app"); setError(null); }} > {mode === "app" ? "Use a backup code" : "Use your authenticator app"} </button> <button type="button" className="text-muted-foreground transition-colors duration-150 hover:text-foreground" onClick={() => toast.add({ title: "Code sent", description: "We texted the phone ending in 4417.", }) } > Text a code to the phone ending in 4417 </button> </div> <p className="mt-10 text-center text-sm text-pretty text-muted-foreground"> Can't use any of these? Ask a Cedarline admin to reset your two-factor. </p> </AuthCentered> );}Account state: paused
The owner's view of a workspace paused for billing. Members see who to contact instead of the payment button.
import { Button } from "@oration/canon/components/button";import { MonogramTile } from "@oration/canon/components/monogram-tile";import { toast } from "@oration/canon/components/toast";import { AccountHeader, AuthCentered, AuthHeading } from "./chrome";export function Paused() { return ( <AuthCentered header={<AccountHeader email="maya@cedarline.com" />}> <AuthHeading media={ <div className="flex items-center gap-3 rounded-xl bg-card p-4 shadow-border"> <MonogramTile name="Cedarline" color="teal" size="lg" /> <div className="min-w-0"> <p className="text-sm font-medium">Cedarline</p> <p className="text-13 text-muted-foreground"> Growth plan </p> </div> </div> } title="Cedarline is paused" description="We couldn't charge the card on file for the Growth plan. Update your payment method to bring everyone back. Agents, sequences and workflows are paused, and nothing is deleted." /> <div className="mt-8 flex flex-col gap-2"> <Button size="lg" className="w-full" onClick={() => toast.add({ title: "Billing", description: "Opening payment methods.", }) } > Update payment method </Button> <Button variant="outline" size="lg" className="w-full"> Go to your workspaces </Button> <Button variant="ghost" size="lg" className="w-full text-muted-foreground" > Log out </Button> </div> <p className="mt-10 text-center text-sm text-muted-foreground"> Paused workspaces keep their data for 60 days. </p> </AuthCentered> );}Account state: suspended
A member whose access to one workspace was suspended. It names the date and the owner to contact.
import { Button } from "@oration/canon/components/button";import { MonogramTile } from "@oration/canon/components/monogram-tile";import { AccountHeader, AuthCentered, AuthHeading } from "./chrome";export function Suspended() { return ( <AuthCentered header={<AccountHeader email="jordan@cedarline.com" />}> <AuthHeading media={ <div className="flex items-center gap-3 rounded-xl bg-card p-4 shadow-border"> <MonogramTile name="Cedarline" color="teal" size="lg" /> <div className="min-w-0"> <p className="text-sm font-medium">Cedarline</p> <p className="text-13 text-muted-foreground tabular-nums"> 48 members </p> </div> </div> } title="Your access to Cedarline is suspended" description="An admin suspended your membership on September 28, 2026. Your account and your other workspaces aren't affected. If you think this is a mistake, contact Maya Okafor, Cedarline's owner." /> <div className="mt-8 flex flex-col gap-2"> <Button size="lg" className="w-full"> Go to your workspaces </Button> <Button variant="ghost" size="lg" className="w-full text-muted-foreground" > Log out </Button> </div> </AuthCentered> );}Measurements#
From the auth layouts. Auth pages use the large 36px controls throughout.
| Part | Value | Classes |
|---|---|---|
| Form column | 24rem | w-full max-w-sm |
| Header | 64px, 24px sides | h-16 px-6 |
| Main padding | 24px sides, 32px top, 64px bottom | px-6 pt-8 pb-16 |
| Footer | 56px | h-14 px-6 |
| Split | Halves from 1024px, 45 and 55 from 1280px | lg:grid-cols-2 xl:grid-cols-[45fr_55fr] |
| Product panel | 8px inset, 12px corners | sticky top-0 h-svh p-2, rounded-xl bg-sidebar |
| Controls | 36px | size="lg", inputs h-9 |
| Heading to providers | 32px | mt-8 |
| Separator | 24px above and below | my-6 |
| Between fields | 16px | FieldGroup className="gap-4" |
| Code slots | 6 slots, 48px tall, 2 groups of 3 | h-12 flex-1 text-lg |
| Icon tile | 40px, 12px corners | size-10 rounded-xl shadow-border |
Input OTP's slots are 32px with 14px text and no size prop, so sign in overrides them to 44 to 48px at 18px, and paints its own error and success borders because aria-invalid on the input doesn't reach the slots. See the gaps on Input OTP.
Responsive behavior#
The form column is 24rem at every width. Only the split layout changes, at 1024px.
Below 1024px log in is one column: the wordmark, the form in a 24rem column, the trust row under it, and the footer. The product panel doesn't mount at all.
| Width | What changes |
|---|---|
| 390px | One column; the form fills the width less 24px gutters. The trust row sits under the form on log in and sign up. Inputs use 16px text so iOS doesn't zoom. |
| 768px | Unchanged; the column stays 24rem and centers. |
| 1024px | Log in and sign up split in half; the trust row moves into the panel. |
| 1440px | The split becomes 45 and 55 from 1280px. Centered pages never change. |
States#
Most of what auth pages do is change state, and every state has a sentence that says what to do next.
| Screen | States |
|---|---|
| Log in | Field errors, a wrong password (That email and password don't match), a single sign-on domain that swaps the password field for Continue with your provider, a session-ended note, and lockout after 5 tries for 15 minutes. |
| Sign up | Field errors, the work email hint, password strength from Weak to Strong with three checks, and the terms error. |
| Verify | Entering, wrong (red slots and a shake), expired (a note and Send a new code), resend cooldown, locked after 5 tries, and verified (a green tile, then onward after 600ms). |
| Two-factor | Authenticator, text message with its own resend, backup code, a used backup code, and trust this device for 60 days. |
| Account | Paused (owner and member copy differ), suspended, deleted (Your account was deleted) and review sign-in (Was this you?). |
Starter code#
The two layouts and the shape of a status page.
// app/(auth)/(split)/layout.tsx: log in and sign upexport default function SplitLayout({ children }: { children: React.ReactNode }) { return <AuthSplit panel={<ProductPanel />}>{children}</AuthSplit>;}// components/auth/auth-split.tsxexport function AuthSplit({ panel, children }: { panel: React.ReactNode; children: React.ReactNode }) { return ( <AuthFrame className="lg:grid lg:grid-cols-2 xl:grid-cols-[45fr_55fr]"> <div className="flex min-h-svh flex-col"> <AuthHeader href="/login" /> <main id="main" className="flex flex-1 items-center justify-center px-6 pt-8 pb-16"> <div className="w-full max-w-sm">{children}</div> </main> {/* Below 1024px the trust row sits under the form */} <TrustRow className="justify-center px-6 pb-2 lg:hidden" /> <AuthFooter /> </div> <div className="sticky top-0 hidden h-svh p-2 lg:block">{panel}</div> </AuthFrame> );}// app/(auth)/(centered)/layout.tsx: verification, two-factor, resets and status pagesexport default function CenteredLayout({ children }: { children: React.ReactNode }) { return ( <AuthFrame> <AuthHeader href="/login" /> <AuthMain>{children}</AuthMain> <AuthFooter /> </AuthFrame> );}// A status page: heading with media, then stacked full-width actions<AuthHeading media={<AuthIconTile><KeyRoundIcon /></AuthIconTile>} title="Enter your verification code" description="…" /><div className="mt-8 flex flex-col gap-2"> <Button size="lg" className="w-full">Verify</Button> <Button variant="ghost" size="lg" className="w-full text-muted-foreground">Log out</Button></div>- Put the trust row only in the split layout, so no other page can show it by accident.
- Every auth page has exactly one filled button. Providers are outline, and secondary routes are ghost buttons or links.
- Codes autofill with
autoComplete="one-time-code"and submit on the sixth digit.
Rules that apply#
The One Filled Button Rule
The Sentence Case Rule
The Machine Mono Rule
The Label-Beside-Color Rule
Do and don't#
- SOC 2
- ISO 27001
- GDPR