Skip to content

Register

Account creation with a live password-strength meter that is announced as words, not bars.

AuthenticationintermediateFeaturedauthsignuppasswordstrength

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 { cn } from '@/lib/cn'
import { Checkbox } from '@/components/ui/choice'
import { Field } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { useDemoForm } from '@/hooks/use-demo-form'
import {
  checked,
  email,
  minLength,
  passwordScore,
  required,
  strongPassword,
} from '@/lib/validation'
import { FormShell } from './_shell'

/**
 * Registration form
 *
 * Demonstrates a live password-strength meter that is genuinely accessible:
 * the score is announced as words in a polite live region, not conveyed by
 * four coloured bars alone.
 *
 * The terms checkbox is validated like any other field, so failing to tick it
 * moves focus to it rather than silently disabling the submit button.
 */
const meterTone = ['bg-danger', 'bg-danger', 'bg-warning', 'bg-success', 'bg-success'] as const

export default function RegisterForm() {
  const form = useDemoForm({
    schema: {
      name: { validators: [required('Full name'), minLength(2, 'Full name')] },
      email: { validators: [required('Email'), email()] },
      password: { validators: [required('Password'), strongPassword()] },
      terms: { initial: 'false', validators: [checked('Accept the terms to create an account.')] },
    },
  })

  const strength = passwordScore(form.values.password ?? '')

  return (
    <FormShell
      title="Create your account"
      description="Fourteen days of the Team plan. No card required."
      formRef={form.formRef}
      onSubmit={form.handleSubmit}
      status={form.status}
      serverError={form.serverError}
      invalidCount={form.invalidCount}
      submitted={form.submitted}
      submitLabel="Create account"
      submittingLabel="Creating account"
      onReset={form.reset}
      successTitle="Account created"
      width="sm"
      footer={
        <>
          Already have an account?{' '}
          <Link
            href="/forms/login"
            className="font-medium text-accent underline underline-offset-4"
          >
            Sign in
          </Link>
        </>
      }
    >
      <Field name="name" label="Full name" required error={form.error('name')}>
        {(field) => (
          <Input {...field} autoComplete="name" placeholder="Priya Raman" {...form.field('name')} />
        )}
      </Field>

      <Field name="email" label="Work 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="At least 10 characters, including a number and a letter."
      >
        {(field) => (
          <div className="flex flex-col gap-2">
            <Input
              {...field}
              type="password"
              autoComplete="new-password"
              {...form.field('password')}
            />
            <div className="flex items-center gap-2">
              <div className="flex flex-1 gap-1" aria-hidden="true">
                {[0, 1, 2, 3].map((index) => (
                  <span
                    key={index}
                    className={cn(
                      'h-1 flex-1 rounded-full transition-colors duration-200',
                      index < strength.score ? meterTone[strength.score] : 'bg-surface-sunken',
                    )}
                  />
                ))}
              </div>
              <span className="w-20 shrink-0 text-right text-xs text-ink-muted">
                {strength.label}
              </span>
            </div>
            {/* The meter is decorative; this is what a screen reader hears. */}
            <p aria-live="polite" className="sr-only">
              {form.values.password ? `Password strength: ${strength.label}` : ''}
            </p>
          </div>
        )}
      </Field>

      <Checkbox
        id="register-terms"
        name="terms"
        label="I accept the terms and privacy policy"
        description="This is a demo — there is nothing to accept."
        checked={form.values.terms === 'true'}
        aria-invalid={form.error('terms') ? true : undefined}
        onChange={(event) => form.setValue('terms', String(event.target.checked))}
      />
      {form.error('terms') ? (
        <p className="-mt-1 text-xs font-medium text-danger">{form.error('terms')}</p>
      ) : null}
    </FormShell>
  )
}

components/blocks/forms/register.tsx

'use client'

import Link from 'next/link'
import { cn } from '@/lib/cn'
import { Checkbox } from '@/components/ui/choice'
import { Field } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { useDemoForm } from '@/hooks/use-demo-form'
import {
  checked,
  email,
  minLength,
  passwordScore,
  required,
  strongPassword,
} from '@/lib/validation'
import { FormShell } from './_shell'

/**
 * Registration form
 *
 * Demonstrates a live password-strength meter that is genuinely accessible:
 * the score is announced as words in a polite live region, not conveyed by
 * four coloured bars alone.
 *
 * The terms checkbox is validated like any other field, so failing to tick it
 * moves focus to it rather than silently disabling the submit button.
 */
const meterTone = ['bg-danger', 'bg-danger', 'bg-warning', 'bg-success', 'bg-success'] as const

export default function RegisterForm() {
  const form = useDemoForm({
    schema: {
      name: { validators: [required('Full name'), minLength(2, 'Full name')] },
      email: { validators: [required('Email'), email()] },
      password: { validators: [required('Password'), strongPassword()] },
      terms: { initial: 'false', validators: [checked('Accept the terms to create an account.')] },
    },
  })

  const strength = passwordScore(form.values.password ?? '')

  return (
    <FormShell
      title="Create your account"
      description="Fourteen days of the Team plan. No card required."
      formRef={form.formRef}
      onSubmit={form.handleSubmit}
      status={form.status}
      serverError={form.serverError}
      invalidCount={form.invalidCount}
      submitted={form.submitted}
      submitLabel="Create account"
      submittingLabel="Creating account"
      onReset={form.reset}
      successTitle="Account created"
      width="sm"
      footer={
        <>
          Already have an account?{' '}
          <Link
            href="/forms/login"
            className="font-medium text-accent underline underline-offset-4"
          >
            Sign in
          </Link>
        </>
      }
    >
      <Field name="name" label="Full name" required error={form.error('name')}>
        {(field) => (
          <Input {...field} autoComplete="name" placeholder="Priya Raman" {...form.field('name')} />
        )}
      </Field>

      <Field name="email" label="Work 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="At least 10 characters, including a number and a letter."
      >
        {(field) => (
          <div className="flex flex-col gap-2">
            <Input
              {...field}
              type="password"
              autoComplete="new-password"
              {...form.field('password')}
            />
            <div className="flex items-center gap-2">
              <div className="flex flex-1 gap-1" aria-hidden="true">
                {[0, 1, 2, 3].map((index) => (
                  <span
                    key={index}
                    className={cn(
                      'h-1 flex-1 rounded-full transition-colors duration-200',
                      index < strength.score ? meterTone[strength.score] : 'bg-surface-sunken',
                    )}
                  />
                ))}
              </div>
              <span className="w-20 shrink-0 text-right text-xs text-ink-muted">
                {strength.label}
              </span>
            </div>
            {/* The meter is decorative; this is what a screen reader hears. */}
            <p aria-live="polite" className="sr-only">
              {form.values.password ? `Password strength: ${strength.label}` : ''}
            </p>
          </div>
        )}
      </Field>

      <Checkbox
        id="register-terms"
        name="terms"
        label="I accept the terms and privacy policy"
        description="This is a demo — there is nothing to accept."
        checked={form.values.terms === 'true'}
        aria-invalid={form.error('terms') ? true : undefined}
        onChange={(event) => form.setValue('terms', String(event.target.checked))}
      />
      {form.error('terms') ? (
        <p className="-mt-1 text-xs font-medium text-danger">{form.error('terms')}</p>
      ) : null}
    </FormShell>
  )
}

lib/validation.ts

/**
 * Validation rules.
 *
 * Small, composable predicates returning either an error string or `null`.
 * Messages are written to be actionable — "Enter your work email" rather than
 * "Invalid" — because an error message is the only part of a form a user reads
 * carefully.
 */
export type Validator = (value: string, values: Record<string, string>) => string | null

const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/
const URL_LIKE = /^(https?:\/\/)?([\w-]+\.)+[\w-]{2,}(\/\S*)?$/
const PHONE = /^[+]?[\d\s()-]{7,20}$/

export const required =
  (label = 'This field'): Validator =>
  (value) =>
    value.trim().length === 0 ? `${label} is required.` : null

export const email =
  (message = 'Enter a valid email address, for example name@company.com.'): Validator =>
  (value) =>
    value.trim().length === 0 || EMAIL.test(value.trim()) ? null : message

export const minLength =
  (length: number, label = 'This field'): Validator =>
  (value) =>
    value.trim().length === 0 || value.trim().length >= length
      ? null
      : `${label} must be at least ${length} characters.`

export const maxLength =
  (length: number, label = 'This field'): Validator =>
  (value) =>
    value.length <= length ? null : `${label} must be ${length} characters or fewer.`

export const url =
  (message = 'Enter a valid URL, for example example.com.'): Validator =>
  (value) =>
    value.trim().length === 0 || URL_LIKE.test(value.trim()) ? null : message

export const phone =
  (message = 'Enter a valid phone number.'): Validator =>
  (value) =>
    value.trim().length === 0 || PHONE.test(value.trim()) ? null : message

export const pattern =
  (regex: RegExp, message: string): Validator =>
  (value) =>
    value.trim().length === 0 || regex.test(value.trim()) ? null : message

export const matches =
  (otherField: string, message: string): Validator =>
  (value, values) =>
    value === (values[otherField] ?? '') ? null : message

export const numeric =
  (message = 'Enter a number.'): Validator =>
  (value) =>
    value.trim().length === 0 || /^-?\d+(\.\d+)?$/.test(value.trim()) ? null : message

export const checked =
  (message = 'This must be accepted to continue.'): Validator =>
  (value) =>
    value === 'true' ? null : message

/** Password strength used across the authentication forms. */
export const strongPassword =
  (message = 'Use at least 10 characters, including a number and a letter.'): Validator =>
  (value) => {
    if (value.length === 0) return null
    const longEnough = value.length >= 10
    const hasLetter = /[a-zA-Z]/.test(value)
    const hasNumber = /\d/.test(value)
    return longEnough && hasLetter && hasNumber ? null : message
  }

export function passwordScore(value: string): { score: 0 | 1 | 2 | 3 | 4; label: string } {
  if (!value) return { score: 0, label: 'Empty' }
  let score = 0
  if (value.length >= 10) score += 1
  if (value.length >= 14) score += 1
  if (/[a-z]/.test(value) && /[A-Z]/.test(value)) score += 1
  if (/\d/.test(value) && /[^\w\s]/.test(value)) score += 1
  const labels = ['Very weak', 'Weak', 'Fair', 'Strong', 'Very strong'] as const
  const clamped = Math.min(4, score) as 0 | 1 | 2 | 3 | 4
  return { score: clamped, label: labels[clamped] }
}

export function runValidators(
  value: string,
  values: Record<string, string>,
  validators: Validator[],
): string | null {
  for (const validate of validators) {
    const error = validate(value, values)
    if (error) return error
  }
  return null
}

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

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

Usage

The strength meter is the interesting part: four coloured bars mean nothing to a screen-reader user, so the score is also announced as text in a polite live region.

  • The terms checkbox is validated like any other field, so failing to tick it moves focus to it rather than silently disabling submit.
  • Password rules are stated up front in the help text, not revealed by failure.
  • `new-password` autocomplete lets a manager generate and store a password.

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
  • Weak password
  • Terms not accepted
  • Loading
  • Success

Accessibility

Meter announcement
The visual meter is `aria-hidden`; a live region announces “Password strength: Fair”.
Required marking
Required fields carry both an asterisk and `aria-required`.
Focus order
DOM order matches visual order, so tabbing follows the form.

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