FSAI Design System
Patterns

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:

ApproachWhen to useExample
Zod schemas with zodResolverComplex forms, multi-step wizards, auth flowsresolver: zodResolver(schema)
Inline register rules with formValidationsSimple modal forms with a few fieldsregister('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:

PairWhy
First Name / Last NameNaturally read together
Email / PhoneContact details
City / StateAddress parts
Start Date / End DateDate ranges
Amount / CurrencyMonetary 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:

ComponentRoleUse when
SelectStandard field component built on PickerThe user is choosing from a known list in a normal form field
AutocompleteSelectSearch-first field component built on PickerTyping to filter or discover options is the main interaction
PickerLow-level primitiveYou 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:

ComponentUse whenAvoid when
CheckboxThe 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
ToggleThe UI is expressing a product setting or feature state such as active, enabled, hidden, send immediately, add meeting link, or private/public style behaviorThe user is selecting rows/items in a list or choosing between multiple explicit options that should be compared side by side
RadioThe user must choose one explicit option from a small set, including yes/no, mode, strategy, or mutually exclusive workflow behaviorThe 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

ComponentRoleUse when
DateInputStandard field-style date pickerYou want a normal labeled date field in a form or filter
DateSelectorStandalone calendarYou need a custom trigger or custom popover layout
TimeSelectTime dropdown built on SelectThe 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>

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


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 Modal on top of the current modal
  • do not open a Panel on top of the current modal
  • if the form needs another step, keep it in the same modal or move to a Panel or 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:

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-4 when space permits
  • Do use space-y-5 for 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 label on every form field
  • Do add a placeholder showing the expected format or example value
  • Do mark required fields with * in the label
  • Do use Icon on Input for contextual reinforcement (search, URL, location)
  • Do use Select as the default dropdown field and AutocompleteSelect when 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 Picker in 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 error prop
  • Do use formValidations from @fsai/sdk for 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-form with useForm for all form state management
  • Do keep multi-step modal forms inside one modal instead of stacking overlays
  • Don't use the legacy formId / action prop pattern on Modal — use primitives instead
  • Don't open a nested Modal or a Panel on top of a modal form
  • Don't build custom form state with useState for field values — use react-hook-form

On this page