Skip to content

Reset password

Two fields with a cross-field match rule and a strength meter.

Authenticationstarterauthpasswordconfirmationcross-field

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

/**
 * Reset password
 *
 * Two fields with a cross-field rule. The confirmation validator reads the
 * whole values object, which is why `useDemoForm` passes it — a rule that can
 * only see its own field cannot express "these must match".
 */
export default function ResetPasswordForm() {
  const form = useDemoForm({
    schema: {
      password: { validators: [required('Password'), strongPassword()] },
      confirm: {
        validators: [required('Confirmation'), matches('password', 'Both passwords must match.')],
      },
    },
  })

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

  return (
    <FormShell
      title="Choose a new password"
      description="Signed in sessions on other devices will be ended."
      formRef={form.formRef}
      onSubmit={form.handleSubmit}
      status={form.status}
      serverError={form.serverError}
      invalidCount={form.invalidCount}
      submitted={form.submitted}
      submitLabel="Update password"
      submittingLabel="Updating"
      onReset={form.reset}
      successTitle="Password updated"
      width="sm"
    >
      <Field
        name="password"
        label="New 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',
                      index < strength.score
                        ? strength.score >= 3
                          ? 'bg-success'
                          : strength.score === 2
                            ? 'bg-warning'
                            : 'bg-danger'
                        : 'bg-surface-sunken',
                    )}
                  />
                ))}
              </div>
              <span className="w-20 shrink-0 text-right text-xs text-ink-muted">
                {strength.label}
              </span>
            </div>
          </div>
        )}
      </Field>

      <Field name="confirm" label="Confirm new password" required error={form.error('confirm')}>
        {(field) => (
          <Input
            {...field}
            type="password"
            autoComplete="new-password"
            {...form.field('confirm')}
          />
        )}
      </Field>
    </FormShell>
  )
}

components/blocks/forms/reset-password.tsx

'use client'

import { cn } from '@/lib/cn'
import { Field } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { useDemoForm } from '@/hooks/use-demo-form'
import { matches, passwordScore, required, strongPassword } from '@/lib/validation'
import { FormShell } from './_shell'

/**
 * Reset password
 *
 * Two fields with a cross-field rule. The confirmation validator reads the
 * whole values object, which is why `useDemoForm` passes it — a rule that can
 * only see its own field cannot express "these must match".
 */
export default function ResetPasswordForm() {
  const form = useDemoForm({
    schema: {
      password: { validators: [required('Password'), strongPassword()] },
      confirm: {
        validators: [required('Confirmation'), matches('password', 'Both passwords must match.')],
      },
    },
  })

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

  return (
    <FormShell
      title="Choose a new password"
      description="Signed in sessions on other devices will be ended."
      formRef={form.formRef}
      onSubmit={form.handleSubmit}
      status={form.status}
      serverError={form.serverError}
      invalidCount={form.invalidCount}
      submitted={form.submitted}
      submitLabel="Update password"
      submittingLabel="Updating"
      onReset={form.reset}
      successTitle="Password updated"
      width="sm"
    >
      <Field
        name="password"
        label="New 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',
                      index < strength.score
                        ? strength.score >= 3
                          ? 'bg-success'
                          : strength.score === 2
                            ? 'bg-warning'
                            : 'bg-danger'
                        : 'bg-surface-sunken',
                    )}
                  />
                ))}
              </div>
              <span className="w-20 shrink-0 text-right text-xs text-ink-muted">
                {strength.label}
              </span>
            </div>
          </div>
        )}
      </Field>

      <Field name="confirm" label="Confirm new password" required error={form.error('confirm')}>
        {(field) => (
          <Input
            {...field}
            type="password"
            autoComplete="new-password"
            {...form.field('confirm')}
          />
        )}
      </Field>
    </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
}

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

Usage

Demonstrates a validator that reads the whole values object. A rule that can only see its own field cannot express “these must match”, which is why every validator in Foundry receives all values.

  • Re-validation of the confirmation happens as the first field changes, so the error clears itself.
  • The form states that other sessions will end — a consequence people should not discover afterwards.

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
  • Mismatch
  • Weak password
  • Success

Accessibility

Cross-field errors
The mismatch is reported on the confirmation field, where the user can act on it.
Autocomplete
Both fields use `new-password` so managers offer to update the stored entry.

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