/sign-upSign up
A split signup page: name, email, and a password with a live strength meter beside a brand panel of highlights, and consent gated before the account is ever created.
The panel is in the accessibility tree: it carries the headline, the copy and the highlights beside the card, and only the brand mark and the hairline are hidden as decoration. The score comes from getPasswordStrength, the same function PasswordInput's own meter uses, so a password never reads "Good" in one place and "Fair" in another; only the bar is this block's. The eight-character minimum stays a form rule rather than a scoring rule — the meter reports how hard a password is to guess, the minimum is a policy the account enforces, and a seven-character password with a digit and a symbol can read "Fair" and still be refused on submit. The meter is a PercentageBar hidden from assistive technology — colour and width say the same thing twice — with the verdict spoken from a polite live region beside it. Consent is checked before every other field, since everything else ends in an account being created; that refusal carries no `field`, which is what puts it in the form-level alert above the button — an unticked box is not invalid input, it is a step not taken, and it is never painted red. Every other error goes under the field `field` names. The mock treats taken@example.com as already registered. The page takes one prop, `layout`, and is the one auth block that defaults to "split": the brand panel beside the card carries the product's highlights from data.ts through AuthFrame's `aside`, folded away below lg rather than stacked, and "centered" drops the panel and the highlights with it. A template picks the layout; the page forwards it. Composes AuthFrame (block-local), Callout, Input, PasswordInput, PercentageBar, Label, Checkbox, and Button.
Preview
import Link from "next/link"
import { CircleCheckIcon } from "lucide-react"
import { Callout } from "@/components/ui/callout"
import { AuthFrame, type AuthLayout } from "./components/auth-frame"
import { SignUpForm } from "./components/sign-up-form"
import { BRAND, HIGHLIGHTS, SIGN_IN_HREF } from "./data"
/**
* Create an account. A server component: only the form and its meter are
* client islands, and the page renders outside AppShell, so the frame owns
* the single `main`.
*
* Split by default, unlike the other four: this is the page a stranger lands
* on, so it has something to say beside the form. What it says is decoration —
* the panel is `aria-hidden`, and below `lg` there is no panel at all.
*/
export default function SignUpPage({ layout = "split" }: { layout?: AuthLayout } = {}) {
return (
<AuthFrame
title="Create your account"
description="Fourteen days of everything, no card. Use your work email so your team can find you."
brand={BRAND}
layout={layout}
aside={
<div className="flex max-w-sm flex-col gap-6">
{/* An h2, never an h1: the form's title is the page's heading. */}
<div className="flex flex-col gap-3">
<h2 className="text-2xl leading-snug font-semibold tracking-tight text-balance">
Everything your team measures, in one place.
</h2>
<p className="text-sm leading-6 text-pretty text-muted-foreground">
One workspace for the numbers you report on, the alerts you act on, and the trail
that says who changed what.
</p>
</div>
<ul className="flex flex-col gap-3">
{HIGHLIGHTS.map((highlight) => (
<li key={highlight} className="flex items-start gap-2.5 text-sm leading-6">
<CircleCheckIcon className="mt-1 size-4 shrink-0 text-success" />
<span className="text-pretty text-muted-foreground">{highlight}</span>
</li>
))}
</ul>
</div>
}
footer={
<>
Already have an account?{" "}
<Link href={SIGN_IN_HREF} className="text-foreground underline underline-offset-4">
Sign in
</Link>
</>
}
>
<Callout className="text-xs">
This demo runs on the mock adapter:{" "}
<span className="font-medium text-foreground">taken@example.com</span> is always already
registered.
</Callout>
<SignUpForm />
</AuthFrame>
)
}Install
npx shadcn@latest add @vibra/auth-sign-upNeeds the @vibra registry in your components.json — set it up once.
Source
import Link from "next/link"
import { CircleCheckIcon } from "lucide-react"
import { Callout } from "@/components/ui/callout"
import { AuthFrame, type AuthLayout } from "./components/auth-frame"
import { SignUpForm } from "./components/sign-up-form"
import { BRAND, HIGHLIGHTS, SIGN_IN_HREF } from "./data"
/**
* Create an account. A server component: only the form and its meter are
* client islands, and the page renders outside AppShell, so the frame owns
* the single `main`.
*
* Split by default, unlike the other four: this is the page a stranger lands
* on, so it has something to say beside the form. What it says is decoration —
* the panel is `aria-hidden`, and below `lg` there is no panel at all.
*/
export default function SignUpPage({ layout = "split" }: { layout?: AuthLayout } = {}) {
return (
<AuthFrame
title="Create your account"
description="Fourteen days of everything, no card. Use your work email so your team can find you."
brand={BRAND}
layout={layout}
aside={
<div className="flex max-w-sm flex-col gap-6">
{/* An h2, never an h1: the form's title is the page's heading. */}
<div className="flex flex-col gap-3">
<h2 className="text-2xl leading-snug font-semibold tracking-tight text-balance">
Everything your team measures, in one place.
</h2>
<p className="text-sm leading-6 text-pretty text-muted-foreground">
One workspace for the numbers you report on, the alerts you act on, and the trail
that says who changed what.
</p>
</div>
<ul className="flex flex-col gap-3">
{HIGHLIGHTS.map((highlight) => (
<li key={highlight} className="flex items-start gap-2.5 text-sm leading-6">
<CircleCheckIcon className="mt-1 size-4 shrink-0 text-success" />
<span className="text-pretty text-muted-foreground">{highlight}</span>
</li>
))}
</ul>
</div>
}
footer={
<>
Already have an account?{" "}
<Link href={SIGN_IN_HREF} className="text-foreground underline underline-offset-4">
Sign in
</Link>
</>
}
>
<Callout className="text-xs">
This demo runs on the mock adapter:{" "}
<span className="font-medium text-foreground">taken@example.com</span> is always already
registered.
</Callout>
<SignUpForm />
</AuthFrame>
)
}/**
* Everything this page reads. What it changes lives in `actions.ts` beside it,
* where the adapter runs on the server; this file holds only the vocabulary
* the page and its client island share.
*/
/** The workspace this page is branded for. */
export const BRAND = { name: "Northwind", initial: "N" }
/** What the panel beside the form says. Copy, not data. */
export const HIGHLIGHTS = [
"Traffic, revenue, and incidents on one token system.",
"Dark mode that matches, on every screen.",
"Blocks you install as routes, not as a package.",
]
export const SIGN_IN_HREF = "/sign-in"
export const TERMS_HREF = "/terms"
export const PRIVACY_HREF = "/privacy"
/**
* The adapter's floor, and a form rule rather than a scoring rule. The shared
* `getPasswordStrength` counts a digit and a symbol whatever the length, so a
* seven-character password can still read "Fair" on the meter — and then be
* refused here on submit. That split is deliberate: the meter reports how hard
* a password is to guess, and the minimum is a policy the account enforces.
* The two answer different questions and should not be folded together.
*/
export const MIN_PASSWORD_LENGTH = 8"use server"
import {
createAuthActions,
isFormData,
mockAuthAdapter,
NOT_A_FORM,
type AuthResult,
} from "@/lib/auth-adapter"
/**
* What this page changes. `createAuthActions` validates the submitted FormData
* and only then calls the adapter, so pointing the page at a real backend is a
* one-line change here: hand it your own adapter instead of the mock and
* nothing above this file moves. The adapter stays on this side of the
* boundary — its session is module state, and a real one holds a secret.
*/
const actions = createAuthActions(mockAuthAdapter)
/**
* Signs up from the form's FormData: name, email, password, terms.
*
* Consent is checked before anything else, because everything else ends in an
* account being created — there is no point validating a name for a signup
* that must not happen. The refusal carries no `field`, which is what puts it
* in the form-level alert above the button — an unticked box is not invalid
* input, it is a step not taken, and it should not be painted red.
*/
export async function signUp(formData: FormData): Promise<AuthResult> {
if (!isFormData(formData)) return { ok: false, error: NOT_A_FORM }
if (formData.get("terms") === null) {
return {
ok: false,
error: { code: "invalid_input", message: "Accept the terms to continue." },
}
}
return actions.signUp(formData)
}import * as React from "react"
export type AuthLayout = "centered" | "split"
export type AuthFrameProps = {
/** The page's only h1. */
title: string
description: React.ReactNode
brand: { name: string; initial: string }
/** Sits under the card, outside its frame — the way off this page. */
footer?: React.ReactNode
/**
* Goes in the split panel, under the brand. Decoration inside decoration:
* the panel is `aria-hidden`, and a "centered" frame has no panel to put it
* in, so nothing here may be the only place the page says something.
*/
aside?: React.ReactNode
/** "centered" (default) is today's single card. "split" adds a brand panel beside the card from `lg` up. */
layout?: AuthLayout
children: React.ReactNode
}
/**
* The frame this page sits in. Auth pages render outside AppShell, so nothing
* else on the route owns a landmark: the frame carries the page's single
* `main` and its single `h1`.
*
* `layout` belongs to the product this page is installed in rather than to the
* page — a template says which one it wants, and the page forwards it. The
* card is the same either way; "split" only sets a brand panel beside it, and
* only from `lg`, where there is room for one. Below that the two layouts are
* the same screen: a phone gets the card, never a panel stacked above it.
*
* Copied into each auth block rather than shared between them. A block
* installs as a self-contained route, so it brings its own frame with it.
*/
export function AuthFrame({
title,
description,
brand,
footer,
aside,
layout = "centered",
children,
}: AuthFrameProps) {
const card = (
<div className="flex w-full max-w-sm flex-col gap-5">
<div className="flex items-center justify-center gap-2">
<span
aria-hidden="true"
className="flex size-6 items-center justify-center rounded-md bg-brand text-2xs font-semibold text-brand-foreground"
>
{brand.initial}
</span>
<span className="text-sm font-medium tracking-tight">{brand.name}</span>
</div>
<section className="flex flex-col gap-5 panel p-6">
<header className="flex flex-col gap-1.5">
<h1 className="type-display text-3xl text-pretty">{title}</h1>
<p className="text-sm text-pretty text-muted-foreground">{description}</p>
</header>
{children}
</section>
{footer ? <div className="text-center text-sm text-muted-foreground">{footer}</div> : null}
</div>
)
if (layout === "split") {
return (
<main
data-slot="auth-frame"
data-layout={layout}
className="flex min-h-svh bg-surface lg:grid lg:grid-cols-2"
>
{/* The panel stays in the accessibility tree: it carries the brand and
whatever the page hands it through `aside` — sign-up's headline and
highlights, for one — so only the mark and the hairline are hidden
as decoration. A surface token rather than the primary colour, so it
reads as a panel in both themes instead of inverting in the dark one. */}
<aside
data-slot="auth-frame-panel"
className="hidden flex-col justify-center gap-6 bg-card border-r border-border px-12 py-10 text-card-foreground lg:flex"
>
<span
aria-hidden="true"
className="flex size-11 items-center justify-center rounded-lg bg-brand text-base font-semibold text-brand-foreground"
>
{brand.initial}
</span>
<div className="flex flex-col gap-3">
<span className="type-display text-3xl">{brand.name}</span>
<span aria-hidden="true" className="h-px w-16 bg-border" />
</div>
{aside}
</aside>
<div className="flex flex-1 items-center justify-center px-4 py-10">{card}</div>
</main>
)
}
return (
<main
data-slot="auth-frame"
data-layout={layout}
className="flex min-h-svh items-center justify-center bg-surface px-4 py-10"
>
{card}
</main>
)
}"use client"
import { getPasswordStrength } from "@/components/ui/password-input"
import { PercentageBar } from "@/components/ui/percentage-bar"
// The meter takes the tone of the score it reached, so the bar reads as one
// verdict rather than a gradient. Tokens, so it follows the theme; the score
// itself comes from the shared scorer, which owns the rules and the wording.
const SCORE_COLORS = [
"var(--muted-foreground)",
"var(--danger)",
"var(--warning)",
"var(--warning)",
"var(--success)",
]
/**
* The strength meter under the password field. The score and its label come
* from `getPasswordStrength`, the same function `PasswordInput`'s own meter
* uses, so a password never reads "Good" in one place and "Fair" in another.
* Only the bar is this block's: `PercentageBar` rather than the four segments
* the primitive draws.
*
* The bar itself is decorative — colour and width say the same thing twice for
* anyone who can see it — so it is hidden from assistive technology and the
* verdict is spoken instead, from a polite live region that only changes when
* the score steps.
*/
export function PasswordStrength({ password }: { password: string }) {
const strength = getPasswordStrength(password)
const percent = strength.score * 25
return (
<div
data-slot="password-strength"
data-score={strength.score}
// pt-1 clears the input's focus ring, which is 3px and would otherwise
// sit on the track.
className="flex flex-col gap-1.5 pt-1"
>
<div aria-hidden="true">
<PercentageBar
height="sm"
showLegend={false}
// An empty meter is bg-muted on a bg-surface page, a one-percent
// step that disappears in light mode and leaves an unexplained gap
// above the hint. The hairline draws the track it sits in.
className="[&_[data-slot=percentage-bar-track]]:border"
segments={[
{ label: strength.label, value: percent, color: SCORE_COLORS[strength.score] },
// The unfilled remainder shows the track through, so the bar keeps
// its full width at every score.
{ label: "remaining", value: 100 - percent, color: "transparent" },
]}
/>
</div>
<p aria-live="polite" className="min-h-4 text-xs text-muted-foreground">
{password ? `Password strength: ${strength.label}` : null}
</p>
</div>
)
}"use client"
import * as React from "react"
import Link from "next/link"
import { type AuthResult } from "@/lib/auth-adapter"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { PasswordInput } from "@/components/ui/password-input"
import { signUp } from "../actions"
import { MIN_PASSWORD_LENGTH, PRIVACY_HREF, TERMS_HREF } from "../data"
import { PasswordStrength } from "./password-strength"
const NAME_ID = "sign-up-name"
const EMAIL_ID = "sign-up-email"
const PASSWORD_ID = "sign-up-password"
const PASSWORD_HINT_ID = "sign-up-password-hint"
const MESSAGE_ID = "sign-up-error"
// A link inside a label would toggle the checkbox on the way to the page it
// points at. Stopping the click before it reaches the label leaves the rest of
// the sentence clickable, which is the half worth keeping.
function stopLabelToggle(event: React.MouseEvent) {
event.stopPropagation()
}
/**
* The signup form. `useActionState` holds the adapter's last answer, so one
* piece of state carries the pending flag, the error, and the new session.
*
* Every field is controlled, for two reasons: React resets an uncontrolled
* form once its action settles, so a taken email would take the name and the
* chosen password down with it; and the meter reads the password on every
* keystroke, which would otherwise leave it reporting a verdict on a field
* the reset had already emptied.
*/
export function SignUpForm() {
const [name, setName] = React.useState("")
const [email, setEmail] = React.useState("")
const [password, setPassword] = React.useState("")
const [result, formAction, pending] = React.useActionState<AuthResult | null, FormData>(
(_previous, formData) => signUp(formData),
null
)
const error = result && !result.ok ? result.error : null
const session = result && result.ok ? result.data : null
const nameInvalid = error?.field === "name"
const emailInvalid = error?.field === "email"
const passwordInvalid = error?.field === "password"
// An error with no field — the missing consent, a rate limit, an outage —
// gets a form-level alert above the button. It never marks the checkbox: an
// unticked box is not invalid input, it is a step not taken, and painting it
// red says the wrong thing to anyone reading the field's state.
const formLevelError = error != null && error.field === undefined
// One message, rendered under whichever field owns it, so the reading order
// and the aria-describedby say the same thing.
const message = error ? (
<p id={MESSAGE_ID} role="alert" className="text-sm text-danger">
{error.message}
</p>
) : null
return (
<form action={formAction} noValidate className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<Label htmlFor={NAME_ID}>Full name</Label>
<Input
id={NAME_ID}
name="name"
autoComplete="name"
placeholder="Ada Lovelace"
required
value={name}
onChange={(event) => setName(event.target.value)}
aria-invalid={nameInvalid || undefined}
aria-describedby={nameInvalid ? MESSAGE_ID : undefined}
/>
{nameInvalid ? message : null}
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor={EMAIL_ID}>Email</Label>
<Input
id={EMAIL_ID}
name="email"
type="email"
autoComplete="email"
placeholder="you@example.com"
required
value={email}
onChange={(event) => setEmail(event.target.value)}
aria-invalid={emailInvalid || undefined}
aria-describedby={emailInvalid ? MESSAGE_ID : undefined}
/>
{emailInvalid ? message : null}
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor={PASSWORD_ID}>Password</Label>
<PasswordInput
id={PASSWORD_ID}
name="password"
autoComplete="new-password"
required
value={password}
onChange={(event) => setPassword(event.target.value)}
aria-invalid={passwordInvalid || undefined}
aria-describedby={
passwordInvalid ? `${PASSWORD_HINT_ID} ${MESSAGE_ID}` : PASSWORD_HINT_ID
}
/>
<PasswordStrength password={password} />
<p id={PASSWORD_HINT_ID} className="text-xs text-muted-foreground">
At least {MIN_PASSWORD_LENGTH} characters. Length, mixed case, a number, and a symbol each
add to the score.
</p>
{passwordInvalid ? message : null}
</div>
<Label className="items-start text-sm font-normal text-muted-foreground">
<Checkbox name="terms" className="mt-0.5" />
<span className="text-pretty">
I agree to the{" "}
<Link
href={TERMS_HREF}
onClick={stopLabelToggle}
className="text-foreground underline underline-offset-4"
>
Terms of Service
</Link>{" "}
and{" "}
<Link
href={PRIVACY_HREF}
onClick={stopLabelToggle}
className="text-foreground underline underline-offset-4"
>
Privacy Policy
</Link>
</span>
</Label>
{formLevelError ? (
<p role="alert" className="text-sm text-danger">
{error.message}
</p>
) : null}
<Button type="submit" disabled={pending || session !== null}>
{pending ? "Creating account…" : "Create account"}
</Button>
{session ? (
<p role="status" className="text-sm text-success">
Account created for {session.user.email}. Check your inbox for the verification code.
</p>
) : null}
</form>
)
}