Skip to contentVibraUI
Shared pagesinstalls at /verify

Verify code

A centered card for confirming an email: a six-digit one-time code in two groups of three, a resend button held shut by a 30-second countdown, and a verified confirmation.

Open the live page

createAuthActions rejects anything that is not six digits before the adapter is called, so "Enter the 6-digit code." and the mock's own "That code is invalid or has expired." (for 000000) both land on the same field, tied by aria-describedby. The resend cooldown is a tick rather than a clock: the seconds start at a constant, so the server render and the hydrating one agree by construction, and useInterval takes one off every second. Nothing in the block calls Date.now(), in render or out of it. The OTP slots are decorative divs, so the invalid state is painted through them from the wrapper rather than set on each one — and only for an error that names the code; a rate limit or an outage reads as a form-level alert above the button instead of marking six digits that may be perfectly correct. When the cooldown ends, a polite live region that was rendered empty from the start says "You can request a new code.", because a button quietly re-enabling announces nothing. Focus moves to the confirmation's h2 (tabIndex -1, focused from the mount effect of its own small component) when it replaces the form. 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), Callout, InputOTP, Label, useInterval, and Button.

Preview

Install

npx shadcn@latest add @vibra/auth-verify-code

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

Source

app/verify/page.tsx
import Link from "next/link"

import { Callout } from "@/components/ui/callout"

import { AuthFrame, type AuthLayout } from "./components/auth-frame"
import { VerifyCodeForm } from "./components/verify-code-form"
import { BRAND, CODE_LIFETIME, PENDING_EMAIL, SIGN_IN_HREF } from "./data"

/**
 * Confirm an email address with a six-digit code. A server component: only
 * the form and its cooldown are client islands, and the page renders outside
 * AppShell, so the frame owns the single `main`.
 */
export default function VerifyCodePage({ layout = "centered" }: { layout?: AuthLayout } = {}) {
  return (
    <AuthFrame
      title="Verify your email"
      description={
        <>
          We sent a six-digit code to{" "}
          <span className="text-foreground">{PENDING_EMAIL}</span>. It expires in{" "}
          {CODE_LIFETIME}.
        </>
      }
      brand={BRAND}
      layout={layout}
      footer={
        <Link href={SIGN_IN_HREF} className="text-foreground underline underline-offset-4">
          Back to sign in
        </Link>
      }
    >
      <Callout className="text-xs">
        This demo runs on the mock adapter: any six digits work except{" "}
        <span className="font-mono">000000</span>.
      </Callout>

      <VerifyCodeForm />
    </AuthFrame>
  )
}
app/verify/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"
export const DASHBOARD_HREF = "/saas"

/** Where the code was sent, and how long it is good for. */
export const PENDING_EMAIL = "ada@northwind.example"
export const CODE_LIFETIME = "10 minutes"

/** How long the resend button stays shut after a code goes out. */
export const RESEND_SECONDS = 30

/** How many digits the code has, and how many slots the field draws. */
export const CODE_LENGTH = 6
app/verify/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)

/**
 * Checks the code from the form's FormData: code. Anything that is not six
 * digits comes back as invalid_code without the adapter being called.
 */
export async function verifyCode(formData: FormData): Promise<AuthResult<{ verified: true }>> {
  return actions.verifyCode(formData)
}
app/verify/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/verify/components/resend-code.tsx
"use client"

import * as React from "react"

import { useInterval } from "@/hooks/use-interval"
import { Button } from "@/components/ui/button"

import { RESEND_SECONDS } from "../data"

/**
 * The resend control and the cooldown that holds it shut.
 *
 * The cooldown is a tick, not a clock. State starts at a constant, so the
 * server render and the hydrating one agree by construction, and `useInterval`
 * takes a second off it every second — the only place time is read at all.
 * Nothing here calls `Date.now()`, in render or out of it, so there is no
 * moment where two renders could disagree about how long is left. Passing
 * `null` as the delay is how the interval stops without being torn down.
 */
export function ResendCode() {
  const [secondsLeft, setSecondsLeft] = React.useState(RESEND_SECONDS)
  const ready = secondsLeft <= 0

  useInterval(() => setSecondsLeft((remaining) => Math.max(0, remaining - 1)), ready ? null : 1000)

  return (
    <div className="flex flex-wrap items-center gap-x-3 gap-y-2 border-t pt-4">
      <Button
        type="button"
        variant="outline"
        size="sm"
        disabled={!ready}
        // The mock has no resend of its own; a real adapter would send a fresh
        // code from here before the cooldown restarts.
        onClick={() => setSecondsLeft(RESEND_SECONDS)}
      >
        Resend code
      </Button>

      {ready ? null : (
        // A number that changes every second is noise, not news: the label
        // carries the reading and the ticks stay silent.
        <span
          role="timer"
          aria-live="off"
          aria-label={`A new code can be sent in ${secondsLeft} seconds`}
          className="text-xs text-muted-foreground"
        >
          Available again in{" "}
          <span className="font-medium text-foreground tabular-nums">{secondsLeft}s</span>
        </span>
      )}

      {/* Rendered from the start, empty, so the browser has a region to
          announce into when the cooldown ends — a live region that appears
          already holding its text is often missed. The button re-enabling is
          silent on its own; this is what says so. */}
      <span aria-live="polite" className="text-xs text-muted-foreground">
        {ready ? "You can request a new code." : null}
      </span>
    </div>
  )
}
app/verify/components/verify-code-form.tsx
"use client"

import * as React from "react"
import Link from "next/link"
import { MailCheckIcon } from "lucide-react"

import { cn } from "@/lib/utils"
import { type AuthResult } from "@/lib/auth-adapter"
import { Button, buttonVariants } from "@/components/ui/button"
import {
  InputOTP,
  InputOTPGroup,
  InputOTPSeparator,
  InputOTPSlot,
} from "@/components/ui/input-otp"
import { Label } from "@/components/ui/label"

import { verifyCode } from "../actions"
import { CODE_LENGTH, DASHBOARD_HREF, PENDING_EMAIL } from "../data"
import { ResendCode } from "./resend-code"

const CODE_ID = "verify-code-input"
const MESSAGE_ID = "verify-code-error"

// Two groups of three, which is how a six-digit code is read aloud.
const FIRST_HALF = [0, 1, 2]
const SECOND_HALF = [3, 4, 5]

const SLOT_CLASS = "size-11 text-base"

/**
 * 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>
  )
}

/** The code field, its cooldown, and the confirmation that replaces both. */
export function VerifyCodeForm() {
  const [result, formAction, pending] = React.useActionState<
    AuthResult<{ verified: true }> | null,
    FormData
  >((_previous, formData) => verifyCode(formData), null)

  const error = result && !result.ok ? result.error : null
  // Only an error that names the code belongs on the field. Anything else — a
  // rate limit, an outage — is about the attempt, not the six digits, and
  // reads above the button as a form-level alert rather than marking a code
  // that may be perfectly correct.
  const codeInvalid = error?.field === "code"

  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>Email verified</ConfirmationHeading>
            <p role="status" className="text-sm text-pretty text-muted-foreground">
              {PENDING_EMAIL} is confirmed. Your workspace is ready.
            </p>
          </div>
        </div>

        <Link href={DASHBOARD_HREF} className={buttonVariants()}>
          Go to dashboard
        </Link>
      </div>
    )
  }

  return (
    <div className="flex flex-col gap-4">
      <form action={formAction} noValidate className="flex flex-col gap-4">
        <div className="flex flex-col gap-2">
          <Label htmlFor={CODE_ID}>Verification code</Label>
          {/* The slots are decorative divs, so the invalid state is painted
              through them from here rather than set on each one. */}
          <div className={cn(codeInvalid && "[&_[data-slot=input-otp-slot]]:border-danger")}>
            <InputOTP
              id={CODE_ID}
              name="code"
              maxLength={CODE_LENGTH}
              autoComplete="one-time-code"
              containerClassName="justify-start"
              aria-invalid={codeInvalid || undefined}
              aria-describedby={codeInvalid ? MESSAGE_ID : undefined}
            >
              <InputOTPGroup>
                {FIRST_HALF.map((index) => (
                  <InputOTPSlot key={index} index={index} className={SLOT_CLASS} />
                ))}
              </InputOTPGroup>
              <InputOTPSeparator />
              <InputOTPGroup>
                {SECOND_HALF.map((index) => (
                  <InputOTPSlot key={index} index={index} className={SLOT_CLASS} />
                ))}
              </InputOTPGroup>
            </InputOTP>
          </div>
        </div>

        {codeInvalid ? (
          <p id={MESSAGE_ID} role="alert" className="text-sm text-danger">
            {error.message}
          </p>
        ) : null}

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

        <Button type="submit" disabled={pending}>
          {pending ? "Verifying…" : "Verify email"}
        </Button>
      </form>

      <ResendCode />
    </div>
  )
}