Skip to contentVibraUI
Shared pagesinstalls at /forgot-password

Forgot password

A centered recovery card: one address field, and a check-your-inbox confirmation that never reveals whether the account exists.

Open the live page

The adapter answers the same way whether or not the address is registered, because an endpoint that says "no such account" is an endpoint that enumerates your users — so the confirmation copy is conditional too ("if an account exists for ..."). The confirmation replaces the form and carries its own h2, which keeps the page's single h1 on the frame; focus moves to that h2 (tabIndex -1, focused from the mount effect of its own small component) so a reader is not dropped at the top of the document when the form disappears. Only an error naming the address is painted on it — anything else (a rate limit, an outage) is about the request rather than the field and reads as a form-level alert above the button. useActionState has no reset, so "use a different address" remounts the attempt by key rather than unpicking its state from within. The page takes one prop, `layout`: "centered" (the default, and what the docs preview and a plain install render) or "split", which sets a brand panel beside the card from lg up. A template picks it; the page forwards it to AuthFrame. Composes AuthFrame (block-local), Input, Label, and Button.

Preview

Install

npx shadcn@latest add @vibra/auth-forgot-password

Needs the @vibra registry in your components.json — set it up once.

Source

app/forgot-password/page.tsx
import Link from "next/link"

import { AuthFrame, type AuthLayout } from "./components/auth-frame"
import { ForgotPasswordForm } from "./components/forgot-password-form"
import { BRAND, SIGN_IN_HREF } from "./data"

/**
 * Request a password reset. A server component: only the form is a client
 * island, and the page renders outside AppShell, so the frame owns the single
 * `main`.
 */
export default function ForgotPasswordPage({ layout = "centered" }: { layout?: AuthLayout } = {}) {
  return (
    <AuthFrame
      title="Reset your password"
      // Not an instruction ("tell us your address"), which would go stale the
      // moment the confirmation replaces the form under this same heading.
      description="We email a one-time link to the address on the account."
      brand={BRAND}
      layout={layout}
      footer={
        <Link href={SIGN_IN_HREF} className="text-foreground underline underline-offset-4">
          Back to sign in
        </Link>
      }
    >
      <ForgotPasswordForm />
    </AuthFrame>
  )
}
app/forgot-password/data.ts
/**
 * 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 card is branded for. */
export const BRAND = { name: "Northwind", initial: "N" }

export const SIGN_IN_HREF = "/sign-in"

/** How long the emailed link stays good for, in the copy and in your mailer. */
export const LINK_LIFETIME = "30 minutes"
app/forgot-password/actions.ts
"use server"

import {
  createAuthActions,
  mockAuthAdapter,
  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)

/**
 * Requests a reset link from the form's FormData: email.
 *
 * The adapter answers the same way whether or not the address is registered —
 * an endpoint that says "no such account" is an endpoint that enumerates your
 * users — so the page's success copy is deliberately conditional too.
 */
export async function requestPasswordReset(formData: FormData): Promise<AuthResult<{ sent: true }>> {
  return actions.requestPasswordReset(formData)
}
app/forgot-password/components/auth-frame.tsx
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>
  )
}
app/forgot-password/components/forgot-password-form.tsx
"use client"

import * as React from "react"
import { MailCheckIcon } from "lucide-react"

import { type AuthResult } from "@/lib/auth-adapter"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"

import { requestPasswordReset } from "../actions"
import { LINK_LIFETIME } from "../data"

const EMAIL_ID = "forgot-password-email"
const MESSAGE_ID = "forgot-password-error"

/**
 * The confirmation's heading. It is its own component so that mounting it *is*
 * the event: the effect runs exactly when the confirmation replaces the form,
 * and never on the form's own mount. `tabIndex={-1}` makes it a focus target
 * without putting it in the tab order, so a reader whose focus was on the
 * submit button is moved to what replaced it instead of being dropped at the
 * top of the document.
 */
function ConfirmationHeading({ children }: { children: React.ReactNode }) {
  const ref = React.useRef<HTMLHeadingElement>(null)
  React.useEffect(() => {
    ref.current?.focus()
  }, [])

  return (
    <h2 ref={ref} tabIndex={-1} className="text-base font-semibold tracking-tight">
      {children}
    </h2>
  )
}

/**
 * One attempt at a reset request. `useActionState` has no reset, and after a
 * send there is nothing left to correct in place — so "use a different
 * address" remounts this component from the outside rather than unpicking its
 * state from within.
 */
function ResetRequest({ onUseAnotherAddress }: { onUseAnotherAddress: () => void }) {
  const [email, setEmail] = React.useState("")
  const [result, formAction, pending] = React.useActionState<AuthResult<{ sent: true }> | null, FormData>(
    (_previous, formData) => requestPasswordReset(formData),
    null
  )

  const error = result && !result.ok ? result.error : null
  // Only an error that names the address belongs on it. Anything else — a
  // rate limit, an outage — is about the request, not the field, and reads
  // above the button as a form-level alert rather than painting a valid
  // address red.
  const emailInvalid = error?.field === "email"

  if (result?.ok) {
    return (
      <div className="flex flex-col gap-5">
        <div className="flex flex-col items-start gap-3">
          <span
            aria-hidden="true"
            className="flex size-9 items-center justify-center rounded-full bg-success-muted text-success"
          >
            <MailCheckIcon className="size-4" />
          </span>
          <div className="flex flex-col gap-1.5">
            <ConfirmationHeading>Check your inbox</ConfirmationHeading>
            {/* Conditional on purpose: the adapter never says whether the
                address is registered, and neither should the page. */}
            <p role="status" className="text-sm text-pretty text-muted-foreground">
              If an account exists for <span className="text-foreground">{email}</span>, a reset
              link is on its way. The link expires in {LINK_LIFETIME}.
            </p>
          </div>
        </div>

        <Button type="button" variant="outline" onClick={onUseAnotherAddress}>
          Use a different address
        </Button>
      </div>
    )
  }

  return (
    <form action={formAction} noValidate className="flex flex-col gap-4">
      <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 ? (
          <p id={MESSAGE_ID} role="alert" className="text-sm text-danger">
            {error.message}
          </p>
        ) : null}
      </div>

      {error && !emailInvalid ? (
        <p role="alert" className="text-sm text-danger">
          {error.message}
        </p>
      ) : null}

      <Button type="submit" disabled={pending}>
        {pending ? "Sending…" : "Send reset link"}
      </Button>
    </form>
  )
}

/** The request form, and the confirmation that replaces it. */
export function ForgotPasswordForm() {
  const [attempt, setAttempt] = React.useState(0)
  return (
    <ResetRequest key={attempt} onUseAnotherAddress={() => setAttempt((count) => count + 1)} />
  )
}