Skip to contentVibraUI
Shared pagesinstalls at /sign-in

Sign in

A centered sign-in card: email and password with inline errors tied to the field that failed, a remember switch, links to recovery and signup, and single sign-on placeholders.

Open the live page

The page is a server component; only the form is a client island, holding the adapter's last answer in useActionState so one piece of state carries the pending flag, the error, and the session. The error renders once and is tied by aria-describedby to whichever field the adapter named — email for a malformed address, password for invalid credentials and for anything the adapter cannot pin down. The mock rejects the password "wrong". Success never navigates: the block is rendered in a docs preview as often as it is installed as a route, and a redirect would 404 inside that frame — so it confirms the session and links to NEXT_ROUTE (exported from data.ts, default "/saas"). An app that wants to move the reader automatically calls router.push(NEXT_ROUTE) from the form's success branch instead. The single sign-on buttons are placeholders carrying aria-disabled rather than disabled, so they stay focusable and the note explaining why they are off actually reaches the reader; they no-op on click. 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, Input, PasswordInput, Label, Checkbox, and Button.

Preview

Install

npx shadcn@latest add @vibra/auth-sign-in

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

Source

app/sign-in/page.tsx
import Link from "next/link"

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

import { AuthFrame, type AuthLayout } from "./components/auth-frame"
import { SignInForm } from "./components/sign-in-form"
import { SsoOptions } from "./components/sso-options"
import { BRAND, SIGN_UP_HREF } from "./data"

/**
 * Sign in. A server component: only the form itself is a client island, and
 * the page renders outside AppShell, so the frame owns the single `main`.
 */
export default function SignInPage({ layout = "centered" }: { layout?: AuthLayout } = {}) {
  return (
    <AuthFrame
      title="Sign in"
      description="Welcome back. Use your work email to reach the Northwind workspace."
      brand={BRAND}
      layout={layout}
      footer={
        <>
          New here?{" "}
          <Link href={SIGN_UP_HREF} className="text-foreground underline underline-offset-4">
            Create an account
          </Link>
        </>
      }
    >
      <Callout className="text-xs">
        This demo runs on the mock adapter: any password works except{" "}
        <span className="font-mono">wrong</span>.
      </Callout>

      <SignInForm />
      <SsoOptions />
    </AuthFrame>
  )
}
app/sign-in/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" }

/**
 * Where a signed-in reader goes next. The block never navigates on its own —
 * it renders a link to this route and lets the reader take it. An app that
 * wants to move them automatically calls `router.push(NEXT_ROUTE)` from the
 * form's success branch instead.
 */
export const NEXT_ROUTE = "/saas"

export const FORGOT_PASSWORD_HREF = "/forgot-password"
export const SIGN_UP_HREF = "/sign-up"

/** Federated providers, rendered as placeholders until you wire them up. */
export const SSO_PROVIDERS = ["GitHub", "Google"]
app/sign-in/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, so the form imports these functions rather
 * than the adapter — `useActionState` takes a server action as it is.
 */
const actions = createAuthActions(mockAuthAdapter)

/** Signs in from the form's FormData: email, password, remember. */
export async function signIn(formData: FormData): Promise<AuthResult> {
  return actions.signIn(formData)
}
app/sign-in/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/sign-in/components/sign-in-form.tsx
"use client"

import * as React from "react"
import Link from "next/link"

import { type AuthResult } from "@/lib/auth-adapter"
import { Button, buttonVariants } 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 { signIn } from "../actions"
import { FORGOT_PASSWORD_HREF, NEXT_ROUTE } from "../data"

const EMAIL_ID = "sign-in-email"
const PASSWORD_ID = "sign-in-password"
const MESSAGE_ID = "sign-in-error"

/**
 * The credentials form. `useActionState` holds the adapter's last answer, so
 * one piece of state carries the pending flag, the error, and the session.
 *
 * The email is controlled because React resets an uncontrolled form once its
 * action settles: a rejected password would otherwise take the address down
 * with it and make the reader type it again. The password is left to reset,
 * which is what should happen to one that was wrong.
 *
 * Success does not navigate. The block is rendered in a docs preview as often
 * as it is installed as a route, and a redirect to /saas would 404 inside
 * that frame — so the reader is told what happened and given the link. An app
 * calls `router.push(NEXT_ROUTE)` here instead.
 */
export function SignInForm() {
  const [email, setEmail] = React.useState("")
  const [result, formAction, pending] = React.useActionState<AuthResult | null, FormData>(
    (_previous, formData) => signIn(formData),
    null
  )

  const error = result && !result.ok ? result.error : null
  const session = result && result.ok ? result.data : null
  // The adapter names the field an error belongs to. Anything it cannot pin
  // down — a rate limit, an unknown failure — lands on the password, the only
  // other thing this form can be wrong about.
  const emailInvalid = error?.field === "email"
  const passwordInvalid = error != null && !emailInvalid

  // 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={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">
        <div className="flex items-baseline justify-between gap-3">
          <Label htmlFor={PASSWORD_ID}>Password</Label>
          <Link
            href={FORGOT_PASSWORD_HREF}
            className="text-xs text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
          >
            Forgot password?
          </Link>
        </div>
        <PasswordInput
          id={PASSWORD_ID}
          name="password"
          required
          aria-invalid={passwordInvalid || undefined}
          aria-describedby={passwordInvalid ? MESSAGE_ID : undefined}
        />
        {passwordInvalid ? message : null}
      </div>

      <Label className="text-sm font-normal text-muted-foreground">
        <Checkbox name="remember" defaultChecked />
        Remember me for 30 days
      </Label>

      {session ? (
        <Link href={NEXT_ROUTE} className={buttonVariants()}>
          Continue to your dashboard
        </Link>
      ) : (
        <Button type="submit" disabled={pending}>
          {pending ? "Signing in…" : "Sign in"}
        </Button>
      )}

      {session ? (
        <p role="status" className="text-sm text-success">
          Signed in as {session.user.name}.
        </p>
      ) : null}
    </form>
  )
}
app/sign-in/components/sso-options.tsx
import { Button } from "@/components/ui/button"

import { SSO_PROVIDERS } from "../data"

const NOTE_ID = "sign-in-sso-note"

/**
 * The federated options, switched off on purpose: the mock adapter has no
 * OAuth behind it, and a button that silently does nothing is worse than one
 * that says why.
 *
 * They carry `aria-disabled` rather than `disabled`, because a disabled
 * control is skipped by the keyboard and passed over by most screen readers —
 * so the note explaining why it is off would never reach the reader who most
 * needs it. Each button stays focusable, announces itself as disabled, and is
 * described by that note.
 *
 * Nothing here is a client component, and nothing needs to be: a
 * `type="button"` outside the form with no handler on it already does nothing
 * when activated, by mouse or by keyboard. Adding an onClick to say so would
 * cost the whole file its server boundary. Give each provider a handler and
 * drop the aria-disabled to switch them on — at which point this file takes a
 * "use client" directive with it.
 */
export function SsoOptions() {
  return (
    <div className="flex flex-col gap-3">
      <div aria-hidden="true" className="flex items-center gap-3">
        <span className="h-px flex-1 bg-border" />
        <span className="text-xs text-muted-foreground">or continue with</span>
        <span className="h-px flex-1 bg-border" />
      </div>

      <div className="grid gap-2 sm:grid-cols-2">
        {SSO_PROVIDERS.map((provider) => (
          <Button
            key={provider}
            type="button"
            variant="outline"
            aria-disabled="true"
            aria-describedby={NOTE_ID}
            className="aria-disabled:pointer-events-none aria-disabled:opacity-50"
          >
            Continue with {provider}
          </Button>
        ))}
      </div>

      <p id={NOTE_ID} className="text-xs text-muted-foreground">
        Single sign-on is a placeholder here. Point these at your own provider to switch them on.
      </p>
    </div>
  )
}