Forgot password
A reset request whose success message deliberately does not confirm whether the account exists.
Live preview
Source
This exact file renders the preview above.
'use client'
import Link from 'next/link'
import { Field } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { Alert } from '@/components/ui/alert'
import { useDemoForm } from '@/hooks/use-demo-form'
import { email, required } from '@/lib/validation'
import { FormShell } from './_shell'
/**
* Forgot password
*
* The success message deliberately does not confirm whether the address exists
* — "if an account exists, we have sent a link" is the correct wording, because
* confirming existence turns a reset form into an account-enumeration tool.
*/
export default function ForgotPasswordForm() {
const form = useDemoForm({
schema: { email: { validators: [required('Email'), email()] } },
})
return (
<FormShell
title="Reset your password"
description="We will send a reset link to the address on your account."
formRef={form.formRef}
onSubmit={form.handleSubmit}
status={form.status}
serverError={form.serverError}
invalidCount={form.invalidCount}
submitted={form.submitted}
submitLabel="Send reset link"
submittingLabel="Sending"
onReset={form.reset}
successTitle="Check your inbox"
successBody={
<p>
If an account exists for that address, a reset link is on its way. This is a template
demo. No submission was sent.
</p>
}
width="sm"
footer={
<Link href="/forms/login" className="font-medium text-accent underline underline-offset-4">
Back to sign in
</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>
<Alert tone="neutral">Reset links expire after 30 minutes and can only be used once.</Alert>
</FormShell>
)
}
components/blocks/forms/forgot-password.tsx
'use client'
import Link from 'next/link'
import { Field } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { Alert } from '@/components/ui/alert'
import { useDemoForm } from '@/hooks/use-demo-form'
import { email, required } from '@/lib/validation'
import { FormShell } from './_shell'
/**
* Forgot password
*
* The success message deliberately does not confirm whether the address exists
* — "if an account exists, we have sent a link" is the correct wording, because
* confirming existence turns a reset form into an account-enumeration tool.
*/
export default function ForgotPasswordForm() {
const form = useDemoForm({
schema: { email: { validators: [required('Email'), email()] } },
})
return (
<FormShell
title="Reset your password"
description="We will send a reset link to the address on your account."
formRef={form.formRef}
onSubmit={form.handleSubmit}
status={form.status}
serverError={form.serverError}
invalidCount={form.invalidCount}
submitted={form.submitted}
submitLabel="Send reset link"
submittingLabel="Sending"
onReset={form.reset}
successTitle="Check your inbox"
successBody={
<p>
If an account exists for that address, a reset link is on its way. This is a template
demo. No submission was sent.
</p>
}
width="sm"
footer={
<Link href="/forms/login" className="font-medium text-accent underline underline-offset-4">
Back to sign in
</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>
<Alert tone="neutral">Reset links expire after 30 minutes and can only be used once.</Alert>
</FormShell>
)
}
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
The wording is the design decision: “if an account exists, we have sent a link” is correct, because confirming existence turns a reset form into an account-enumeration tool.
- Success copy is identical whether or not the address is known.
- Link expiry is stated before submission, so the user knows to check promptly.
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
- Loading
- Neutral success
Accessibility
- Neutral confirmation
- The success state cannot be used to probe for valid accounts.
- Single field
- One input, one action — nothing to get lost in.
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.
Related
All formsLogin
Email and password with a form-level rejection that does not reveal which half was wrong.
starterFeatured6 variantsReset password
Two fields with a cross-field match rule and a strength meter.
starter4 variantsMagic link
A one-field sign-in whose success state names the address and offers a correction path.
starter4 variants