Forms
A form is a Stack of FormControls, each wrapping one field, followed by one row of actions with a single primary button. FormControl wires the id, htmlFor, aria-describedby, aria-invalid, required, and disabled between its label, field, caption, and validation, so app code never sets those by hand.
- Every text-like field (
TextInput,Textarea,NumberInput,Select,DatePicker) is wrapped in aFormControlcontaining, in this order:FormControl.Label, the field, an optionalFormControl.Caption, an optionalFormControl.Validation. - One field per
FormControl. Two inputs that belong together (a min and a max) are twoFormControls side by side in aGrid, not one control with two fields. Checkbox,Switch, andRadioGroupcarry their ownlabel(andcaption) and are not wrapped in aFormControl.RadioGrouprenders the<fieldset>/<legend>;Radiochildren each have alabeland a uniquevalue.- The label is always visible.
FormControl.Label visuallyHiddenis allowed only for a single search-style field whose purpose is obvious from aleadingVisualicon andplaceholder, such as a toolbar search. A form with two or more fields shows every label. - A placeholder is never the label and never the help text.
placeholdershows an example of the expected format only ("name@example.com"). Instructions go inFormControl.Caption. - Required fields are marked with
requiredon theFormControl. It propagatesrequiredto the field and renders the marker on the label. Required is never signalled by text alone (“(required)”) or by colour alone, and optional fields are not marked. - A field-level error is
FormControl.Validation variant="error"inside that field’sFormControl. It sets the field to invalid (aria-invalid, danger border) and is announced througharia-describedby. Field errors never appear in aBanneror aToast. - A form-level or server error (the request failed, the combination of fields is invalid) is a
Banner variant="danger"placed above the fields inside the form. Known field errors from the server are still mirrored into each field’sFormControl.Validation. - Validation text says what happened and how to fix it in one sentence, sentence case, no exclamation mark: “Enter a valid email address.” (see content.md).
variant="success"confirms a field only when confirmation carries information (a username is available), not after every valid keystroke. - Errors are shown on submit or on blur after the user has left the field, not on the first keystroke. The submit button stays enabled; validation runs and the first invalid field receives focus.
- Field widths come from layout, not from the field. A field fills its column with
fullWidthinside aStack.ItemorGrid.Item. Short values (NumberInput,DatePicker, a postcode) sit in a narrowerGrid.Item spanrather than stretching full width. Pixel widths are never set on a field. (blockis the deprecated 0.3.0 name forfullWidth; it still works with a warning.) - Fields are stacked in a
<Stack gap="normal">. Related groups get aHeading level={3}(orlevel={4}) above their ownStack; a form is never split into oneCardper field. - Actions sit in one
<Stack direction="horizontal" gap="condensed">after the fields. Exactly oneButton variant="primary"per form; the cancel or back action isvariant="invisible"orvariant="default". In aDialog, actions go infooterwith the primary action last. - Button labels are verbs that name the outcome: “Save changes”, “Create story”, “Send invite”. “Submit”, “OK”, and “Yes” are not labels. A destructive submit is
variant="danger"and names the object: “Delete article”. - The form is a native
<form onSubmit>; every field has anameso posting and autofill work.SelectandDatePickeremit hidden inputs forname. - While submitting, the primary button is
disabledand shows aSpinner size="small"with a present-tense label (“Saving…”); fields are not hidden or replaced.
Do / Don’t
Section titled “Do / Don’t”- ✅
<form onSubmit={submit}><Stack gap="normal">{serverError && <Banner variant="danger" title="Could not save the story">{serverError}</Banner>}<FormControl required><FormControl.Label>Headline</FormControl.Label><TextInput name="headline" fullWidth /><FormControl.Caption>Shown in search results and social previews.</FormControl.Caption>{errors.headline && <FormControl.Validation variant="error">{errors.headline}</FormControl.Validation>}</FormControl><Grid columns={{ base: 1, sm: 2 }} gap="normal"><FormControl><FormControl.Label>Section</FormControl.Label><Select name="section" options={sections} placeholder="Choose a section" fullWidth /></FormControl><FormControl><FormControl.Label>Publish date</FormControl.Label><DatePicker name="publishAt" fullWidth /></FormControl></Grid><Checkbox name="notify" label="Notify subscribers" caption="Sends the daily digest early." /><Stack direction="horizontal" gap="condensed"><Button variant="invisible" type="button" onClick={cancel}>Cancel</Button><Button variant="primary" type="submit">Save changes</Button></Stack></Stack></form>
- ✅
<FormControl><FormControl.Label visuallyHidden>Search stories</FormControl.Label><TextInput leadingVisual={<SearchIcon />} placeholder="Search stories" /></FormControl>(the one case for a hidden label) - ❌
<TextInput placeholder="Headline" />with noFormControl.Label(placeholder as label; disappears on input, no accessible name) - ❌
<FormControl.Caption>Required</FormControl.Caption>(required by text; userequiredon theFormControl) - ❌
toast({ variant: 'danger', message: 'Headline is required' })for a field error (the error must sit on the field asFormControl.Validation) - ❌
<label>Email</label><input style={{ border: '1px solid #ddd2bf' }} />(raw elements, label not associated, hardcoded chrome) - ❌
<Button variant="primary">Submit</Button><Button variant="primary">Save draft</Button>(two primaries; “Submit” is not a label) - ❌
<TextInput style={{ width: 280 }} />(pixel width; size theGrid.Item, usefullWidth)