Patterns
Forms and validation
When to check fields, where to show errors and where focus goes.
Forms sit behind every screen that collects data. This pattern fixes when validation runs, where error text goes and how focus moves, so users get the same feedback in every form.
When to use
Section titled “When to use”- On every screen where the user fills in several fields and submits them at once.
- For settings, profile, sign-up and any data entry other than search.
- For single-field inline editing only the inline error rule applies; no summary is needed.
Live example
Section titled “Live example”Submit the empty form. The error summary takes focus; each link in it moves to its field. Errors clear as soon as a field is fixed.
import { Button, Checkbox, Heading, Link, Stack, Text, TextInput, Textarea } from '@bigbang/ui';import { useRef, useState, type FormEvent } from 'react';import { type Lang, LangProvider } from '../lang';
const copy = { tr: { title: 'Profil bilgileri', name: 'Ad soyad', email: 'E-posta adresi', emailHint: 'Bildirimler bu adrese gelir.', note: 'Not', consent: 'Kullanım koşullarını okudum', submit: 'Kaydet', saved: 'Kaydedildi', summary: (count: number) => `Formda ${count} hata var`, errors: { name: 'Ad soyad girin.', emailEmpty: 'E-posta adresi girin.', emailFormat: 'E-posta adresini ad@alanadi.com biçiminde girin.', note: 'Not en fazla 200 karakter olabilir.', consent: 'Devam etmek için kullanım koşullarını onaylayın.', }, }, en: { title: 'Profile details', name: 'Full name', email: 'Email address', emailHint: 'Notifications go to this address.', note: 'Note', consent: 'I have read the terms of use', submit: 'Save', saved: 'Saved', summary: (count: number) => `There ${count === 1 ? 'is 1 error' : `are ${count} errors`} in the form`, errors: { name: 'Enter your full name.', emailEmpty: 'Enter an email address.', emailFormat: 'Enter the email address as name@domain.com.', note: 'The note can be 200 characters at most.', consent: 'Accept the terms of use to continue.', }, },};
type Values = { name: string; email: string; note: string; consent: boolean };type FieldKey = keyof Values;type Errors = Partial<Record<FieldKey, string>>;
const order: FieldKey[] = ['name', 'email', 'note', 'consent'];
export function validate(values: Values, lang: Lang): Errors { const t = copy[lang].errors; const errors: Errors = {}; if (!values.name.trim()) errors.name = t.name; if (!values.email.trim()) errors.email = t.emailEmpty; else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(values.email.trim())) errors.email = t.emailFormat; if (values.note.length > 200) errors.note = t.note; if (!values.consent) errors.consent = t.consent; return errors;}
/** * Validate on submit, then re-check a field as it changes so its error clears as soon as it is fixed. * The error summary takes focus and links to each field; inline errors repeat the message at the field. */export function ValidationForm({ lang = 'tr', initial }: { lang?: Lang; initial?: Partial<Values> }) { const t = copy[lang]; const [values, setValues] = useState<Values>({ name: '', email: '', note: '', consent: false, ...initial }); const [errors, setErrors] = useState<Errors>({}); const [submitted, setSubmitted] = useState(false); const [status, setStatus] = useState<'idle' | 'saving' | 'saved'>('idle'); const summaryRef = useRef<HTMLDivElement>(null); const formRef = useRef<HTMLFormElement>(null);
const update = <K extends FieldKey>(key: K, value: Values[K]) => { const next = { ...values, [key]: value }; setValues(next); setStatus('idle'); // After the first submit, errors follow the value; before it, nobody is told off while typing. if (submitted) setErrors(validate(next, lang)); };
const focusField = (key: FieldKey) => { formRef.current?.querySelector<HTMLElement>(`[data-field="${key}"] input:not([type="hidden"]), [data-field="${key}"] textarea, [data-field="${key}"] [role="checkbox"]`)?.focus(); };
const onSubmit = (event: FormEvent) => { event.preventDefault(); const found = validate(values, lang); setErrors(found); setSubmitted(true); if (Object.keys(found).length > 0) { // Focus moves once, to the summary; it is announced as a whole. requestAnimationFrame(() => summaryRef.current?.focus()); return; } setStatus('saving'); setTimeout(() => setStatus('saved'), 1200); };
const listed = order.filter((key) => errors[key]); // `error` is optional and exact, so it is only passed when there is one. const errorOf = (key: FieldKey) => (errors[key] ? { error: errors[key] } : {});
return ( <LangProvider lang={lang}> <form ref={formRef} noValidate onSubmit={onSubmit} className="w-full max-w-md" aria-labelledby={`form-title-${lang}`}> <Stack gap="xl"> <Heading level={2} size="md" id={`form-title-${lang}`}> {t.title} </Heading> {submitted && listed.length > 0 ? ( <div ref={summaryRef} tabIndex={-1} role="alert" aria-labelledby={`form-summary-${lang}`} className="rounded-md border border-status-danger-border bg-status-danger-background p-lg outline-none focus-visible:focus-outline" data-part="error-summary" > <Stack gap="sm"> <Text weight="medium" tone="danger" id={`form-summary-${lang}`}> {t.summary(listed.length)} </Text> <ul className="m-0 list-disc ps-xl"> {listed.map((key) => ( <li key={key}> <Link href={`#${key}`} onClick={(event) => { event.preventDefault(); focusField(key); }} > {errors[key]} </Link> </li> ))} </ul> </Stack> </div> ) : null} <div data-field="name"> <TextInput label={t.name} required name="name" autoComplete="name" value={values.name} onValueChange={(value) => update('name', value)} {...errorOf('name')} /> </div> <div data-field="email"> <TextInput label={t.email} description={t.emailHint} required name="email" type="email" autoComplete="email" value={values.email} onValueChange={(value) => update('email', value)} {...errorOf('email')} /> </div> <div data-field="note"> <Textarea label={t.note} necessityIndicator="optional" name="note" maxLength={200} showCount value={values.note} onValueChange={(value) => update('note', value)} {...errorOf('note')} /> </div> <div data-field="consent"> <Checkbox label={t.consent} name="consent" required checked={values.consent} onCheckedChange={(checked) => update('consent', checked)} invalid={Boolean(errors.consent)} /> {errors.consent ? ( <Text size="sm" tone="danger" className="mt-xs"> {errors.consent} </Text> ) : null} </div> <div> <Button type="submit" loading={status === 'saving'} success={status === 'saved'} successLabel={t.saved}> {t.submit} </Button> </div> </Stack> </form> </LangProvider> );}| Rule | Value |
|---|---|
| First validation | When the submit button is pressed. No errors while typing. |
| Later validation | On every change after the first submit; an error clears the moment it is fixed. |
| Error location | Under the field (error prop) and in the summary at the top of the form. |
| Focus | On a submit with errors, focus moves once to the summary. A summary link focuses its field. |
| Required field | Marked with required; a hidden “required” text is read with the label. When few fields are optional, use necessityIndicator="optional". |
| Submit button | Never disabled. Users learn what is missing when they submit. |
| Browser validation | Turned off with noValidate, so messages stay in product language and one style. |
Enter the email address as name@domain.com.
Invalid value.
Behaviour
Section titled “Behaviour”- The user fills in the form. No errors show meanwhile; only informative text such as the character count updates.
- The user presses submit. All fields are checked together.
- With errors, the summary opens at the top of the form and takes focus. Each error shows in the summary and under its field.
- As the user fixes a field, its error and its summary line go away; the count in the summary title updates.
- With no errors left, the submission continues: the button shows loading, then the success mark. See the Saving pattern.
Field errors returned by the server take the same path: written under the field, summary opened, focus on the summary.
Accessibility
Section titled “Accessibility”- The summary has
role="alert"and is named by its title; focus moves to it when it opens, so screen readers read the error count once. - Field errors are tied to the field with
aria-describedbyand setaria-invalid="true"(TextInput and Field do this themselves). - Every summary line is a link that focuses the field’s control.
- Required fields are announced with a hidden “required” text, not only an asterisk.
- Error colour never carries the meaning alone; text is always present (WCAG 1.4.1, 3.3.1, 3.3.3).
Content
Section titled “Content”- Error text says what is wrong and how to fix it: “Enter the email address as name@domain.com.”
- Avoid generic words such as “invalid” or “wrong input”; do not blame the user.
- The summary title gives the count: “There are 3 errors in the form”.
- Labels are short noun phrases (“Email address”); placeholder text never replaces a label.
Motion
Section titled “Motion”| Item | Motion | Preset |
|---|---|---|
| Field border and error colour | 110 ms colour change | motion.field |
| Field error text | 150 ms fade, 4 px drop; no shake | motion.fieldMessage |
| Submit button | 0.97 scale on press; loading and success mark | motion.press, motion.success |
With reduced motion, movement is removed and only the fade stays.
Components used
Section titled “Components used”TextInput, Textarea, Checkbox, Field, Button, Link. In the product, React Hook Form and Zod own form state and schema validation; this pattern only defines the visible behaviour.