Register
Account creation with a live password-strength meter that is announced as words, not bars.
Live preview
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.
Related
All formsLogin
Email and password with a form-level rejection that does not reveal which half was wrong.
starterFeatured6 variantsVerification code
An OTP flow with a resend cooldown and a failure state that does not clear the entered code.
intermediateFeatured5 variantsField
The accessibility contract shared by every form control — label, help text, error, success and the ARIA wiring that connects them.
intermediateFeatured7 variants