Skip to content

Login

Email and password with a form-level rejection that does not reveal which half was wrong.

AuthenticationstarterFeaturedauthsigninpasswordsso

Live preview

full widthLive preview — open it in a new tab for the full-height version.
Open the preview in a new tab

Source

This exact file renders the preview above.

'use client'

import Link from 'next/link'
import { Checkbox } from '@/components/ui/choice'
import { Divider } from '@/components/ui/layout'
import { Field } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { useDemoForm } from '@/hooks/use-demo-form'
import { email, required } from '@/lib/validation'
import { FormShell } from './_shell'

/**
 * Login form
 *
 * Includes the failure path most login demos omit: a server-style rejection
 * that is not attributable to a single field. Signing in with the wrong
 * password produces a form-level error rather than marking the email invalid,
 * because telling an attacker which half was wrong is a security decision, not
 * a UX one.
 *
 * Demo credentials: any email plus the password `foundry`.
 */
export default function LoginForm() {
  const form = useDemoForm({
    schema: {
      email: { validators: [required('Email'), email()] },
      password: { validators: [required('Password')] },
      remember: { initial: 'true' },
    },
    simulateServerError: (values) =>
      values.password === 'foundry'
        ? null
        : 'That email and password combination was not recognised.',
  })

  return (
    <FormShell
      title="Sign in"
      description="Use any email with the password “foundry” to see the success state."
      formRef={form.formRef}
      onSubmit={form.handleSubmit}
      status={form.status}
      serverError={form.serverError}
      invalidCount={form.invalidCount}
      submitted={form.submitted}
      submitLabel="Sign in"
      submittingLabel="Signing in"
      onReset={form.reset}
      successTitle="Signed in"
      successBody={
        <p>This is a template demo. No submission was sent and no session was created.</p>
      }
      width="sm"
      footer={
        <>
          Not registered?{' '}
          <Link
            href="/forms/register"
            className="font-medium text-accent underline underline-offset-4"
          >
            Create an account
          </Link>
        </>
      }
    >
      <Field name="email" label="Email" required error={form.error('email')}>
        {(field) => (
          <Input
            {...field}
            type="email"
            autoComplete="email"
            placeholder="you@company.com"
            {...form.field('email')}
          />
        )}
      </Field>

      <Field
        name="password"
        label="Password"
        required
        error={form.error('password')}
        hint="Demo password: foundry"
      >
        {(field) => (
          <Input
            {...field}
            type="password"
            autoComplete="current-password"
            {...form.field('password')}
          />
        )}
      </Field>

      <div className="flex flex-wrap items-center justify-between gap-2">
        <Checkbox
          id="login-remember"
          name="remember"
          label="Keep me signed in"
          checked={form.values.remember === 'true'}
          onChange={(event) => form.setValue('remember', String(event.target.checked))}
        />
        <Link
          href="/forms/forgot-password"
          className="text-sm text-accent underline underline-offset-4"
        >
          Forgot password?
        </Link>
      </div>

      <Divider label="or" />

      <div className="flex flex-col gap-2">
        <Button type="button" variant="outline" block>
          Continue with SSO
        </Button>
        <Button type="button" variant="outline" block>
          Continue with a magic link
        </Button>
      </div>
    </FormShell>
  )
}

components/blocks/forms/login.tsx

'use client'

import Link from 'next/link'
import { Checkbox } from '@/components/ui/choice'
import { Divider } from '@/components/ui/layout'
import { Field } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { useDemoForm } from '@/hooks/use-demo-form'
import { email, required } from '@/lib/validation'
import { FormShell } from './_shell'

/**
 * Login form
 *
 * Includes the failure path most login demos omit: a server-style rejection
 * that is not attributable to a single field. Signing in with the wrong
 * password produces a form-level error rather than marking the email invalid,
 * because telling an attacker which half was wrong is a security decision, not
 * a UX one.
 *
 * Demo credentials: any email plus the password `foundry`.
 */
export default function LoginForm() {
  const form = useDemoForm({
    schema: {
      email: { validators: [required('Email'), email()] },
      password: { validators: [required('Password')] },
      remember: { initial: 'true' },
    },
    simulateServerError: (values) =>
      values.password === 'foundry'
        ? null
        : 'That email and password combination was not recognised.',
  })

  return (
    <FormShell
      title="Sign in"
      description="Use any email with the password “foundry” to see the success state."
      formRef={form.formRef}
      onSubmit={form.handleSubmit}
      status={form.status}
      serverError={form.serverError}
      invalidCount={form.invalidCount}
      submitted={form.submitted}
      submitLabel="Sign in"
      submittingLabel="Signing in"
      onReset={form.reset}
      successTitle="Signed in"
      successBody={
        <p>This is a template demo. No submission was sent and no session was created.</p>
      }
      width="sm"
      footer={
        <>
          Not registered?{' '}
          <Link
            href="/forms/register"
            className="font-medium text-accent underline underline-offset-4"
          >
            Create an account
          </Link>
        </>
      }
    >
      <Field name="email" label="Email" required error={form.error('email')}>
        {(field) => (
          <Input
            {...field}
            type="email"
            autoComplete="email"
            placeholder="you@company.com"
            {...form.field('email')}
          />
        )}
      </Field>

      <Field
        name="password"
        label="Password"
        required
        error={form.error('password')}
        hint="Demo password: foundry"
      >
        {(field) => (
          <Input
            {...field}
            type="password"
            autoComplete="current-password"
            {...form.field('password')}
          />
        )}
      </Field>

      <div className="flex flex-wrap items-center justify-between gap-2">
        <Checkbox
          id="login-remember"
          name="remember"
          label="Keep me signed in"
          checked={form.values.remember === 'true'}
          onChange={(event) => form.setValue('remember', String(event.target.checked))}
        />
        <Link
          href="/forms/forgot-password"
          className="text-sm text-accent underline underline-offset-4"
        >
          Forgot password?
        </Link>
      </div>

      <Divider label="or" />

      <div className="flex flex-col gap-2">
        <Button type="button" variant="outline" block>
          Continue with SSO
        </Button>
        <Button type="button" variant="outline" block>
          Continue with a magic link
        </Button>
      </div>
    </FormShell>
  )
}

components/ui/field.tsx

import type { ReactNode } from 'react'
import { AlertCircle, CheckCircle2 } from 'lucide-react'
import { cn } from '@/lib/cn'

/**
 * Field
 *
 * The accessibility contract for every form control in Foundry lives here, in
 * one place, rather than being re-implemented per input.
 *
 * Ids are derived deterministically from `name` instead of `useId`, for two
 * reasons: the component stays renderable from a Server Component (no hooks),
 * and the markup is byte-identical between server and client, so a form works
 * before hydration — see `/docs/accessibility`.
 *
 * The render prop hands back the exact wiring a control needs, which makes it
 * impossible to forget `aria-describedby` on a field that has help text.
 */
export interface FieldRenderProps {
  id: string
  name: string
  'aria-describedby': string | undefined
  'aria-invalid': true | undefined
  'aria-required': true | undefined
  required: boolean
  invalid: boolean
}

export interface FieldProps {
  name: string
  label: ReactNode
  hint?: ReactNode
  /** Validation failure. Presence flips the field into its invalid state. */
  error?: string | null
  /** Confirmation message shown once a value has validated. */
  success?: string | null
  required?: boolean
  /** Marks the field "Optional" instead of marking required fields. */
  showOptional?: boolean
  /** Visually hides the label while keeping it available to screen readers. */
  hideLabel?: boolean
  className?: string
  /** Disambiguates ids when the same field name appears twice on one page. */
  idPrefix?: string
  children: (props: FieldRenderProps) => ReactNode
}

export function Field({
  name,
  label,
  hint,
  error,
  success,
  required = false,
  showOptional = false,
  hideLabel = false,
  className,
  idPrefix = 'field',
  children,
}: FieldProps) {
  const id = `${idPrefix}-${name}`
  const hintId = hint ? `${id}-hint` : undefined
  const errorId = error ? `${id}-error` : undefined
  const successId = success && !error ? `${id}-success` : undefined
  const describedBy = [hintId, errorId, successId].filter(Boolean).join(' ') || undefined

  return (
    <div className={cn('flex flex-col gap-1.5', className)}>
      <label
        htmlFor={id}
        className={cn(
          'flex items-baseline gap-1.5 text-sm font-medium text-ink',
          hideLabel && 'sr-only',
        )}
      >
        <span>{label}</span>
        {required && !showOptional ? (
          <span className="text-danger" aria-hidden="true">
            *
          </span>
        ) : null}
        {showOptional && !required ? (
          <span className="text-xs font-normal text-ink-subtle">Optional</span>
        ) : null}
      </label>

      {hint ? (
        <p id={hintId} className="text-xs leading-normal text-ink-muted">
          {hint}
        </p>
      ) : null}

      {children({
        id,
        name,
        'aria-describedby': describedBy,
        'aria-invalid': error ? true : undefined,
        'aria-required': required || undefined,
        required,
        invalid: Boolean(error),
      })}

      {error ? (
        <p id={errorId} className="flex items-start gap-1.5 text-xs font-medium text-danger">
          <AlertCircle className="mt-px size-3.5 shrink-0" aria-hidden="true" />
          <span>{error}</span>
        </p>
      ) : null}

      {success && !error ? (
        <p id={successId} className="flex items-start gap-1.5 text-xs font-medium text-success">
          <CheckCircle2 className="mt-px size-3.5 shrink-0" aria-hidden="true" />
          <span>{success}</span>
        </p>
      ) : null}
    </div>
  )
}

/** Groups related controls (radios, checkboxes) with a shared legend. */
export interface FieldsetProps {
  legend: ReactNode
  hint?: ReactNode
  error?: string | null
  name: string
  required?: boolean
  className?: string
  children: ReactNode
  idPrefix?: string
}

export function Fieldset({
  legend,
  hint,
  error,
  name,
  required = false,
  className,
  children,
  idPrefix = 'group',
}: FieldsetProps) {
  const id = `${idPrefix}-${name}`
  const hintId = hint ? `${id}-hint` : undefined
  const errorId = error ? `${id}-error` : undefined
  const describedBy = [hintId, errorId].filter(Boolean).join(' ') || undefined

  return (
    <fieldset
      className={cn('flex min-w-0 flex-col gap-2', className)}
      aria-describedby={describedBy}
      aria-invalid={error ? true : undefined}
      aria-required={required || undefined}
    >
      <legend className="flex items-baseline gap-1.5 text-sm font-medium text-ink">
        <span>{legend}</span>
        {required ? (
          <span className="text-danger" aria-hidden="true">
            *
          </span>
        ) : null}
      </legend>
      {hint ? (
        <p id={hintId} className="text-xs text-ink-muted">
          {hint}
        </p>
      ) : null}
      {children}
      {error ? (
        <p id={errorId} className="flex items-start gap-1.5 text-xs font-medium text-danger">
          <AlertCircle className="mt-px size-3.5 shrink-0" aria-hidden="true" />
          <span>{error}</span>
        </p>
      ) : null}
    </fieldset>
  )
}

hooks/use-demo-form.ts

'use client'

import { useCallback, useMemo, useRef, useState, type FormEvent } from 'react'
import { runValidators, type Validator } from '@/lib/validation'

export type FormStatus = 'idle' | 'submitting' | 'success' | 'error'

export interface FieldSchema {
  initial?: string
  validators?: Validator[]
}

export interface UseDemoFormOptions<Schema extends Record<string, FieldSchema>> {
  schema: Schema
  /**
   * Simulated latency, in milliseconds, so the loading state is observable.
   * Nothing is sent anywhere.
   */
  latency?: number
  /** Return a message to force a server-style failure for demonstration. */
  simulateServerError?: (values: Record<string, string>) => string | null
  onSuccess?: (values: Record<string, string>) => void
}

/**
 * useDemoForm
 *
 * The shared behaviour behind all 28 Foundry form flows:
 *
 *   - validate on submit, then re-validate that field on every change
 *     (validating on first keystroke punishes people mid-word)
 *   - move focus to the first invalid control, so a keyboard or screen-reader
 *     user is taken to the problem rather than told one exists
 *   - announce the failure count in a polite live region
 *   - expose a server-style error path that is not attached to any one field
 *
 * No request is ever made. `latency` exists purely so the submitting state is
 * long enough to see.
 */
export function useDemoForm<Schema extends Record<string, FieldSchema>>({
  schema,
  latency = 900,
  simulateServerError,
  onSuccess,
}: UseDemoFormOptions<Schema>) {
  const initialValues = useMemo(() => {
    const entries = Object.entries(schema).map(([key, field]) => [key, field.initial ?? ''])
    return Object.fromEntries(entries) as Record<string, string>
  }, [schema])

  const [values, setValues] = useState<Record<string, string>>(initialValues)
  const [errors, setErrors] = useState<Record<string, string | null>>({})
  const [status, setStatus] = useState<FormStatus>('idle')
  const [serverError, setServerError] = useState<string | null>(null)
  const [submitted, setSubmitted] = useState(false)
  const formRef = useRef<HTMLFormElement>(null)
  const timer = useRef<ReturnType<typeof setTimeout> | null>(null)

  const setValue = useCallback(
    (name: string, value: string) => {
      setValues((current) => {
        const next = { ...current, [name]: value }
        // Only re-validate a field the user has already been told about.
        setErrors((currentErrors) => {
          if (currentErrors[name] === undefined) return currentErrors
          const validators = schema[name]?.validators ?? []
          return { ...currentErrors, [name]: runValidators(value, next, validators) }
        })
        return next
      })
      setServerError(null)
    },
    [schema],
  )

  const validateAll = useCallback(
    (currentValues: Record<string, string>) => {
      const next: Record<string, string | null> = {}
      for (const [name, field] of Object.entries(schema)) {
        next[name] = runValidators(currentValues[name] ?? '', currentValues, field.validators ?? [])
      }
      return next
    },
    [schema],
  )

  const focusFirstInvalid = useCallback((nextErrors: Record<string, string | null>) => {
    const firstInvalid = Object.keys(nextErrors).find((name) => nextErrors[name])
    if (!firstInvalid || !formRef.current) return
    const control = formRef.current.querySelector<HTMLElement>(
      `[name="${firstInvalid}"], #field-${firstInvalid}, [data-field="${firstInvalid}"]`,
    )
    control?.focus({ preventScroll: false })
  }, [])

  const handleSubmit = useCallback(
    (event: FormEvent<HTMLFormElement>) => {
      event.preventDefault()
      if (status === 'submitting') return

      const nextErrors = validateAll(values)
      setErrors(nextErrors)
      setSubmitted(true)

      const invalidCount = Object.values(nextErrors).filter(Boolean).length
      if (invalidCount > 0) {
        focusFirstInvalid(nextErrors)
        setStatus('idle')
        return
      }

      setStatus('submitting')
      setServerError(null)

      if (timer.current) clearTimeout(timer.current)
      timer.current = setTimeout(() => {
        const failure = simulateServerError?.(values) ?? null
        if (failure) {
          setStatus('error')
          setServerError(failure)
          return
        }
        setStatus('success')
        onSuccess?.(values)
      }, latency)
    },
    [values, status, validateAll, focusFirstInvalid, simulateServerError, onSuccess, latency],
  )

  const reset = useCallback(() => {
    if (timer.current) clearTimeout(timer.current)
    setValues(initialValues)
    setErrors({})
    setStatus('idle')
    setServerError(null)
    setSubmitted(false)
  }, [initialValues])

  const invalidCount = Object.values(errors).filter(Boolean).length

  return {
    formRef,
    values,
    errors,
    status,
    serverError,
    submitted,
    invalidCount,
    setValue,
    handleSubmit,
    reset,
    /** Convenience: props for a controlled text control. */
    field: (name: string) => ({
      value: values[name] ?? '',
      onChange: (event: { target: { value: string } }) => setValue(name, event.target.value),
    }),
    error: (name: string) => errors[name] ?? null,
    isSubmitting: status === 'submitting',
    isSuccess: status === 'success',
  }
}

Demo source — adapt to your project. Foundry is not published as a package.

Usage

Includes the failure path most login demos omit. Signing in with the wrong password produces a form-level error rather than marking the email invalid — telling an attacker which half was wrong is a security decision, not a UX one. Demo password: foundry.

  • The error is announced through an Alert, not attached to a field.
  • Alternative sign-in methods sit below a labelled divider so they read as alternatives, not extra steps.
  • “Keep me signed in” is a checkbox, not a switch: it applies on submit, not immediately.

Variants and states

Every entry below is a genuine difference in behaviour or layout, and every one of them is visible in the preview above.

  • Idle
  • Invalid email
  • Server-style rejection
  • Loading
  • Success
  • SSO and magic-link alternatives

Accessibility

Error summary
A polite live region reports how many fields need attention after a failed submit.
Autocomplete
`email` and `current-password` tokens let password managers fill the form.
Focus
Failed validation moves focus to the first invalid control.

Foundry implements published ARIA patterns and is tested against them. No WCAG certification is claimed — see the accessibility documentation for what is and is not covered.

All forms