Forms
Patterns for form layout, validation, field grouping, and submission.
Overview
Forms are the primary way users create and edit data across the platform. All forms use react-hook-form for state management and validation. This page covers the structural patterns, layout conventions, and validation rules that keep forms consistent.
Form Library
All forms use react-hook-form. Use useForm directly — there is no custom wrapper.
Two validation approaches are available depending on form complexity:
| Approach | When to use | Example |
|---|---|---|
Zod schemas with zodResolver | Complex forms, multi-step wizards, auth flows | resolver: zodResolver(schema) |
Inline register rules with formValidations | Simple modal forms with a few fields | register('name', { ...formValidations.required }) |
The formValidations object from @fsai/sdk provides reusable validation rules for common field types (required, email, phone, URL, domain, slug).
Field Layout
Vertical Spacing
Use space-y-5 or flex flex-col gap-5 between fields in a form. Auth and full-page forms may use space-y-6 for more breathing room. Prefer flex based layouts.
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
<Input
label="Name"
{...register("name", { ...formValidations.required })}
error={errors.name?.message}
/>
<Input
label="Email"
{...register("email", { ...formValidations.email })}
error={errors.email?.message}
/>
<TextArea label="Notes" {...register("notes")} />
</form>Side-by-Side Fields
Group two related fields on the same row when space allows. Use grid grid-cols-2 gap-4 for consistent alignment.
<div className="grid grid-cols-2 gap-4">
<Input
label="First Name"
placeholder="John"
{...register("firstName", { ...formValidations.required })}
error={errors.firstName?.message}
/>
<Input
label="Last Name"
placeholder="Doe"
{...register("lastName", { ...formValidations.required })}
error={errors.lastName?.message}
/>
</div>Good candidates for side-by-side grouping:
| Pair | Why |
|---|---|
| First Name / Last Name | Naturally read together |
| Email / Phone | Contact details |
| City / State | Address parts |
| Start Date / End Date | Date ranges |
| Amount / Currency | Monetary values |
If the form is in a narrow container (e.g. a side panel), stack fields vertically instead.
Mixed Layouts
Combine full-width and two-column rows in the same form. Full-width fields like TextArea, AddressInput, or file uploads should span the full width between two-column groups.
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
<div className="grid grid-cols-2 gap-4">
<Input
label="Name*"
{...register("name", { ...formValidations.required })}
error={errors.name?.message}
/>
<Controller
control={control}
name="category"
render={({ field }) => (
<Select
label="Category"
options={categories}
selected={field.value}
onSelect={field.onChange}
/>
)}
/>
</div>
<Input
label="Address"
{...register("address")}
placeholder="123 Main St"
fullWidth
/>
<div className="grid grid-cols-2 gap-4">
<Input
label="Email"
{...register("email", { ...formValidations.email })}
error={errors.email?.message}
/>
<Input
label="Phone"
{...register("phone", { ...formValidations.phone })}
error={errors.phone?.message}
/>
</div>
<TextArea label="Description" {...register("description")} />
</form>Selection Inputs
Choose The Right Layer
Selection controls in the design system follow a clear layering:
| Component | Role | Use when |
|---|---|---|
Select | Standard field component built on Picker | The user is choosing from a known list in a normal form field |
AutocompleteSelect | Search-first field component built on Picker | Typing to filter or discover options is the main interaction |
Picker | Low-level primitive | You need a custom trigger, custom popup content, or picker UX outside a standard field |
For ordinary forms, start with Select. Reach for AutocompleteSelect only when search is central to the experience. Use raw Picker when you are building a custom chooser rather than a standard field.
Standard Dropdown
Use Select for most list-based field selection:
<Select
label="Category"
options={categories}
selected={field.value}
onSelect={field.onChange}
/>Search-First Selection
Use AutocompleteSelect when the list is large or grouped and typing is the main interaction:
<AutocompleteSelect
label="Assignee"
options={assigneeOptions}
selected={field.value}
onSelect={field.onChange}
onChangeSearchText={setQuery}
placeholder="Search people"
/>Do not choose AutocompleteSelect just because the backing data is async. Whether you use Select or AutocompleteSelect, the selected option still needs to exist in the available option set so the field can render pre-selected values correctly.
When Raw Picker Fits
Use Picker directly when the interaction is not really a normal form dropdown:
- custom trigger UI
- grouped or highly customized content
- multi-step chooser behavior
- toolbar or panel pickers that are not standard labeled fields
Avoid using raw Picker for a normal form dropdown if Select already fits.
Choice Controls
Checkbox Vs Toggle Vs Radio
These controls solve different problems and should not be treated as interchangeable:
| Component | Use when | Avoid when |
|---|---|---|
Checkbox | The UI is about selection, inclusion, completion, permissions, or select-all behavior. Common in DataTable, multi-select lists, permission matrices, and task-style check states. | The control should read like a standalone product setting or the user must choose one mode from a visible set |
Toggle | The UI is expressing a product setting or feature state such as active, enabled, hidden, send immediately, add meeting link, or private/public style behavior | The user is selecting rows/items in a list or choosing between multiple explicit options that should be compared side by side |
Radio | The user must choose one explicit option from a small set, including yes/no, mode, strategy, or mutually exclusive workflow behavior | The UI is really a standalone on/off setting or supports selecting multiple values |
Checkbox
Use Checkbox for selection lists, permission flags, and partial select-all states:
<Checkbox
checked={allVisibleSelected}
partialChecked={selectedIds.length > 0 && !allVisibleSelected}
onChange={handleToggleAll}
/>Toggle
Use Toggle for a single boolean field:
<Controller
control={control}
name="showBackgroundImage"
render={({ field }) => (
<Toggle checked={field.value} onChange={field.onChange} />
)}
/>Radio
Use Radio for mutually exclusive choices:
<div className="space-y-2">
<Radio checked={value === 'draft'} onChange={() => setValue('draft')} label="Draft" />
<Radio checked={value === 'published'} onChange={() => setValue('published')} label="Published" />
</div>Radio is controlled at the group level by the parent. It is not a native browser radio-group API.
Date And Time Inputs
Choose The Right Layer
| Component | Role | Use when |
|---|---|---|
DateInput | Standard field-style date picker | You want a normal labeled date field in a form or filter |
DateSelector | Standalone calendar | You need a custom trigger or custom popover layout |
TimeSelect | Time dropdown built on Select | The user is choosing a clock time from standard options |
Standard Date Field
<Controller
control={control}
name="startDate"
render={({ field }) => (
<DateInput
label="Start Date"
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
/>
)}
/>Custom Calendar Trigger
<Popover>
<Popover.Button>Select date</Popover.Button>
<Popover.Panel className="w-64">
<DateSelector selected={selectedDate} onSelect={setSelectedDate} direction="future" />
</Popover.Panel>
</Popover>Date And Time Together
Pair DateInput and TimeSelect when the user is scheduling something:
<div className="grid grid-cols-2 gap-4">
<DateInput value={dueDate} onChange={setDueDate} label="Date" />
<TimeSelect value={dueTime} onChange={setDueTime} />
</div>Use direction="future" or direction="past" on DateInput / DateSelector when the schedule only allows one side of time.
Rich And Specialized Fields
Color Input
Use ColorInput when the value is a user-facing color choice and the user should be able to both see the color and edit the hex value:
<Controller
control={control}
name="primaryColor"
render={({ field }) => (
<ColorInput
label="Primary Color"
color={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
/>
)}
/>Use withInput={false} in dense builders or inline forms when the swatch is enough and a separate hex field would take too much space:
<ColorInput
color={field.value}
onChange={field.onChange}
withInput={false}
/>Use plain Input instead if the field is really arbitrary text or a raw token string rather than a user-picked color.
Text Area
Use TextArea for longer freeform text such as notes, summaries, and descriptions:
<TextArea
label="Description"
{...register('description')}
error={errors.description?.message}
/>Address Input
Use AddressInput when selecting an address should also return structured location data:
<AddressInput
label="Address"
value={field.value}
onChange={field.onChange}
onSelectSuggestion={(result) => {
setValue('city', result.city ?? '');
setValue('state', result.state ?? '');
setValue('postcode', result.postcode ?? '');
}}
/>Use plain Input instead if you only need arbitrary text and not real address lookup.
Currency Input
Use CurrencyInput when the field represents a monetary amount and should be formatted like currency rather than treated as a generic number:
<Controller
control={control}
name="defaultPrice"
render={({ field }) => (
<CurrencyInput
label="Default Price"
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
error={errors.defaultPrice?.message}
/>
)}
/>Use plain Input type="number" for counts, percentages, and arbitrary numeric values that are not money.
Range Slider
Use RangeSlider for tuning-style numeric controls:
<Controller
control={control}
name="cornerRadius"
render={({ field }) => (
<RangeSlider
min={0}
max={100}
value={field.value}
onChange={field.onChange}
step={5}
showValueInTooltip
formatValue={(value) => `${value}%`}
/>
)}
/>Quantity Input
Use QuantityInput for menu-domain amount-plus-unit entry. It usually maps to two form fields:
<QuantityInput
amount={quantityField.value}
onChangeAmount={quantityField.onChange}
onChangeUnit={unitField.onChange}
unit={unitField.value}
error={quantityError || unitError}
/>File Input
Use FileInput for single-file upload selection. It should usually be wired through Controller or explicit state setters rather than register(...).
<Controller
control={control}
name="image"
render={({ field }) => (
<FileInput
label="Image"
accept={{ 'image/*': ['.jpg', '.jpeg', '.png'] }}
Icon={Upload}
handleFileChange={field.onChange}
/>
)}
/>ColorInput, TextArea, AddressInput, CurrencyInput, and file-upload controls are usually good full-width fields in mixed form layouts, although compact ColorInput swatches also fit well in dense builder rows.
Field Requirements
Labels
Every form field must have a label. Use the label prop on form components — it renders a consistently styled label above the field. Alternatively, if no label prop exists, use the <Label> component directly to render a label above the field.
Required fields append a redautomatically when therequiredHTML attribute is set. When using react-hook-form register rules for validation (which don't set the HTMLrequiredattribute), add` to the label string manually:
<Input
label="Location Name*"
{...register("name", { ...formValidations.required })}
/>Placeholders
Use placeholders to show the expected format or an example value — not to replace the label.
<Input label="Email" placeholder="contact@example.com" />
<Input label="Phone" placeholder="+1 (555) 000-0000" />
<Input label="Website" placeholder="https://example.com" />Icons
Use the Icon prop on Input for contextual icons that reinforce the field's purpose. Icons are positioned to the left by default.
import { Search, Globe } from '@fsai/icons';
<Input label="Search" Icon={Search} placeholder="Search vendors..." />
<Input label="Website" Icon={Globe} placeholder="https://example.com" />Use labelIcon to place a small icon next to the label text. This is not commonly used, but is available for when it is needed. This should only be added when no icon is already present on the field, and icons on the field take precedence over the label icon.
<Input label="Location" labelIcon="MapPin" {...register("location")} />Validation and Error Display
Validate on Submit, Not on Blur
Do not disable the submit button based on form validity. Instead, let the user attempt submission and show errors on the fields that failed validation. This is the standard react-hook-form handleSubmit behavior — it prevents the onSubmit callback from firing when validation fails and populates the errors object.
const {
register,
handleSubmit,
formState: { errors },
} = useForm({
resolver: zodResolver(schema),
});
<form onSubmit={handleSubmit(onSubmit)}>
<Input label="Email*" {...register("email")} error={errors.email?.message} />
<Button role="button" type="submit" isLoading={isPending}>
Save
</Button>
</form>;When Submit Buttons May Be Disabled
Disable the submit button only when a prerequisite is missing — not when the form is simply invalid. If the submit button is disabled, this should be communicated to the user via a visual component, such as <AlertBox>
| Acceptable | Not acceptable |
|---|---|
| No brand selected (data dependency) | Name field is empty |
| No domain configured (system prereq) | Email format is wrong |
Currently submitting (isLoading) | Form has any validation error |
Inline Notices And Warnings
Use AlertBox when a warning, prerequisite, or informational note should stay visible inside the current form, drawer, or panel.
<AlertBox status="warning">
Select a purpose for this email domain before continuing.
</AlertBox>Use FieldError for field-specific validation and showToast for transient save or mutation feedback.
Error Messages
Pass the error message from react-hook-form directly to the field's error prop. The component handles rendering it in red below the field.
<Input
label="Name*"
{...register("name", { required: "This field is required" })}
error={errors.name?.message}
/>In certain cases where the error prop does not exist, display the error message natively below the field in a paragraph tag.
Modal Forms
Most create/edit forms live inside a Modal. Use the Modal primitives (Modal.Root, Modal.Panel, Modal.Header, Modal.Body, Modal.Footer) and wrap the <form> element around both Modal.Body and Modal.Footer. This gives the form direct ownership of its submit button — no formId bridging required.
Do not stack overlays inside form flows:
- do not open another
Modalon top of the current modal - do not open a
Panelon top of the current modal - if the form needs another step, keep it in the same modal or move to a
Panelor page instead
<Modal.Root isOpen={isOpen} onClose={handleClose}>
<Modal.Backdrop />
<Modal.Panel>
<Modal.Header>
<Modal.Title>Create Vendor</Modal.Title>
<Modal.CloseButton />
</Modal.Header>
<form onSubmit={handleSubmit(onSubmit)} className="flex-1 flex flex-col overflow-hidden">
<Modal.Body className="py-5 px-4">
<div className="flex flex-col gap-5">
<Input
label="Name*"
{...register('name', { ...formValidations.required })}
error={errors.name?.message}
/>
<Input
label="Email"
{...register('email', { ...formValidations.email })}
error={errors.email?.message}
/>
</div>
</Modal.Body>
<Modal.Footer>
<Button role="button" variant="secondary" size="sm" onClick={handleClose}>
Cancel
</Button>
<Button role="button" variant="primary" size="sm" type="submit" isLoading={isPending}>
Create
</Button>
</Modal.Footer>
</form>
</Modal.Panel>
</Modal.Root>The <form> wraps both Modal.Body and Modal.Footer, so the submit button is a native type="submit" button inside the form. The footer renders a Cancel (secondary) + Submit (primary) button pair, right-aligned.
Multi-Step Forms
Split long or complex forms into multiple steps. Three patterns are available depending on context:
Modal with Internal Steps
For create flows with 2–4 stages. Track the current step with useState and render step navigation in the modal header area. Use the Modal primitives for full layout control.
type Step = 'details' | 'permissions';
const [step, setStep] = useState<Step>('details');
<Modal.Root isOpen={isOpen} onClose={handleClose}>
<Modal.Backdrop />
<Modal.Panel>
<Modal.Header>
<Modal.Title>Create Collection</Modal.Title>
<Modal.CloseButton />
</Modal.Header>
<nav className="flex items-center gap-2 border-b border-alpha-default px-4 h-14">
<span className={step === 'details' ? 'font-semibold' : 'text-subtle'}>Details</span>
<ChevronRightSmall className="size-3 text-icon-muted" />
<span className={step === 'permissions' ? 'font-semibold' : 'text-subtle'}>Permissions</span>
</nav>
<Modal.Body>
{step === 'details' && <DetailsStep />}
{step === 'permissions' && <PermissionsStep />}
</Modal.Body>
<Modal.Footer>
<Button role="button" variant="secondary" size="sm" onClick={handleBack}>
{step === 'details' ? 'Cancel' : 'Back'}
</Button>
<Button role="button" variant="primary" size="sm" onClick={handleContinue}>
{step === 'permissions' ? 'Create' : 'Continue'}
</Button>
</Modal.Footer>
</Modal.Panel>
</Modal.Root>Wizard Framework
For full-page onboarding flows with many steps. Steps register their readiness via useWizardStep:
useWizardStep({
ready: isStepReady,
onContinue: handleContinue,
});The wizard shell renders shared Continue / Back navigation.
Page-Based Steps
For applicant-facing forms with dynamic fields. Each page is a subset of fields; navigation buttons (Back / Next / Submit) render below the fields.
Guidelines
Layout
- Do group related fields side by side with
grid grid-cols-2 gap-4when space permits - Do use
space-y-5for vertical spacing between fields - Do stack fields vertically in narrow containers (panels, sidebars)
- Don't exceed two fields per row — three or more columns are too cramped for form inputs
Fields
- Do always provide a
labelon every form field - Do add a
placeholdershowing the expected format or example value - Do mark required fields with
*in the label - Do use
Iconon Input for contextual reinforcement (search, URL, location) - Do use
Selectas the default dropdown field andAutocompleteSelectwhen search is the main interaction - Don't use a placeholder as a substitute for a label
- Don't use icons without a clear purpose — decorative icons add clutter
- Don't reach for raw
Pickerin ordinary forms unless you genuinely need custom composition
Validation
- Do let the user attempt submission and highlight errors on invalid fields
- Do show error messages directly below the field using the
errorprop - Do use
formValidationsfrom@fsai/sdkfor standard rules (required, email, phone, URL) - Don't disable the submit button based on form validation state
- Don't validate on blur for simple forms — validate on submit
Structure
- Do use Modal primitives (
Modal.Root,Modal.Body,Modal.Footer) for modal forms, wrapping the<form>around both Body and Footer - Do split forms with more than ~8 fields into multiple steps or collapsible sections
- Do use
react-hook-formwithuseFormfor all form state management - Do keep multi-step modal forms inside one modal instead of stacking overlays
- Don't use the legacy
formId/actionprop pattern on Modal — use primitives instead - Don't open a nested
Modalor aPanelon top of a modal form - Don't build custom form state with
useStatefor field values — use react-hook-form