Skip to content
BigBang

Popular

    ↑↓Navigate↵OpenEscClose
    TR

    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.

    • 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.

    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.

    Live example, StorybookOpen in Storybook

    Profile details

    Notifications go to this address.

    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.
    DoThe error says how to fix it.
    Invalid value.
    Don'tA generic error makes users guess.
    DoThe submit button stays enabled; missing data is reported on submit.
    Don'tA disabled button gives no reason and cannot take focus.
    1. The user fills in the form. No errors show meanwhile; only informative text such as the character count updates.
    2. The user presses submit. All fields are checked together.
    3. With errors, the summary opens at the top of the form and takes focus. Each error shows in the summary and under its field.
    4. As the user fixes a field, its error and its summary line go away; the count in the summary title updates.
    5. 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.

    • 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-describedby and set aria-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).
    • 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.
    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.

    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.