FairGarden Design

Form

Anything submitted: a Base UI Form, one column at --size-sm, with an error summary at the top and an action row at the end. Instant-apply settings are switches, not forms.

import {
  Form,
  FormActions,
  FormRow,
  FormSummary,
} from '@fairgarden-private/design/components/Form'

The root is the inline-size container form: short pairs share a row from 480 px of form width, framed fieldsets tighten below 360 px, and a butted submit turns icon-only there. Base UI validates on submit and then re-validates failed fields as they change.

Summary, pairs and actions

Summary, pairs and actions

FormSummary is the danger Alert with a type-subhead heading and a link to each failing field; render it only while there are errors, and it takes focus when it appears. FormRow holds a short pair in a 2:1 split. In FormActions, author the solid submit last: it shows first and full width at base and rightmost from --md-n-above. busy makes the form inert while the submit reads "Sending…".

FormStates.tsx
'use client'

import * as React from 'react'
import { Button } from '@fairgarden-private/design/components/Button'
import { Field, FieldError, FieldLabel } from '@fairgarden-private/design/components/Field'
import {
  Form,
  FormActions,
  FormRow,
  FormSummary,
} from '@fairgarden-private/design/components/Form'
import { Input } from '@fairgarden-private/design/components/Input'

type Errors = Record<string, string>

/**
 * Submit with empty fields to see the summary take focus and the fields
 * take the error state; a valid submit shows the busy form for a moment.
 */
export function FormStates() {
  const [errors, setErrors] = React.useState<Errors>({})
  const [busy, setBusy] = React.useState(false)

  const submit = (values: Record<string, unknown>) => {
    const next: Errors = {}
    if (!values.email) next.email = 'Enter an email address, like name@example.com.'
    if (!values.postcode) next.postcode = 'Enter a postcode.'
    setErrors(next)
    if (Object.keys(next).length > 0) return
    setBusy(true)
    window.setTimeout(() => setBusy(false), 1500)
  }

  const count = Object.keys(errors).length

  return (
    <Form errors={errors} onFormSubmit={submit} busy={busy}>
      {count > 0 ? (
        <FormSummary
          title={`Fix ${count} ${count === 1 ? 'field' : 'fields'} to continue`}
          errors={Object.entries(errors).map(([name, message]) => ({
            id: `form-demo-${name}`,
            message,
          }))}
        />
      ) : null}
      <Field name="email">
        <FieldLabel>Email Address</FieldLabel>
        <Input id="form-demo-email" type="email" placeholder="Email address…" />
        <FieldError />
      </Field>
      <FormRow>
        <Field name="city">
          <FieldLabel optional>City</FieldLabel>
          <Input id="form-demo-city" />
        </Field>
        <Field name="postcode">
          <FieldLabel>Postcode</FieldLabel>
          <Input id="form-demo-postcode" />
          <FieldError />
        </Field>
      </FormRow>
      <FormActions>
        <Button type="reset" size="lg">
          Clear
        </Button>
        <Button type="submit" variant="solid" size="lg" aria-busy={busy || undefined}>
          {busy ? 'Sending…' : 'Join the Walk'}
        </Button>
      </FormActions>
    </Form>
  )
}

Primary scale

Primary scale

The Form's primary reaches every field inside it; secondary is accepted and unused.

FormColor.tsx
import { Button } from '@fairgarden-private/design/components/Button'
import { Field, FieldLabel } from '@fairgarden-private/design/components/Field'
import { Form, FormActions } from '@fairgarden-private/design/components/Form'
import { Input } from '@fairgarden-private/design/components/Input'

/** `primary` on the Form reaches every field inside it by inheritance. */
export function FormColor() {
  return (
    <Form primary="plum">
      <Field>
        <FieldLabel>Your Name</FieldLabel>
        <Input />
      </Field>
      <FormActions>
        <Button type="submit" variant="solid" size="lg">
          Send
        </Button>
      </FormActions>
    </Form>
  )
}

On grounds

On paper and forest

On forest the field roles take the deep ground's map and the submit stays amber.

paper

forest

FormGrounds.tsx
import { Button } from '@fairgarden-private/design/components/Button'
import { Field, FieldLabel } from '@fairgarden-private/design/components/Field'
import { Form, FormActions } from '@fairgarden-private/design/components/Form'
import { PresetGround } from '@/components/PresetGround'
import { Input } from '@fairgarden-private/design/components/Input'
import styles from './grounds.module.css'

const presets = ['paper', 'forest'] as const

/** A short form on paper and forest; the submit keeps the scope's action. */
export function FormGrounds() {
  return (
    <div className={styles.row}>
      {presets.map((preset) => (
        <PresetGround key={preset} preset={preset} className={styles.face}>
          <p className={styles.name}>{preset}</p>
          <Form>
            <Field>
              <FieldLabel>Email Address</FieldLabel>
              <Input type="email" />
            </Field>
            <FormActions>
              <Button type="submit" variant="solid" size="lg">
                Sign Up
              </Button>
            </FormActions>
          </Form>
        </PresetGround>
      ))}
    </div>
  )
}

API Reference

Form

A Base UI Form, one column at --size-sm. Base UI validates on submit, then re-validates failed fields as they change (§10.2 asks for blur; pass validationMode="onBlur" to a Field that should wait for it). Order: an optional FormSummary, fields and fieldsets, then FormActions.

PropTypeDescription
busy
boolean | undefined

While the submission is pending: the form is inert and aria-busy. Give the solid submit its “-ing…” label (“Sending…").

primary
| 'ruby'
| 'olive'
| 'sage'
| 'slate'
| 'sand'
| 'gray'
| 'mauve'
| 'brown'
| 'bronze'
| 'gold'
| 'red'
| 'crimson'
| 'tomato'
| 'pink'
| 'plum'
| 'indigo'
| 'iris'
| 'violet'
| 'purple'
| null
| undefined

Primary Radix scale: structure and inherited control roles. Never defaulted [D133].

secondary
| 'ruby'
| 'olive'
| 'sage'
| 'slate'
| 'sand'
| 'gray'
| 'mauve'
| 'brown'
| 'bronze'
| 'gold'
| 'red'
| 'crimson'
| 'tomato'
| 'pink'
| 'plum'
| 'indigo'
| 'iris'
| 'violet'
| 'purple'
| 'amber'
| 'blue'
| 'cyan'
| 'grass'
| 'green'
| 'jade'
| 'lime'
| 'mint'
| 'orange'
| 'sky'
| 'teal'
| 'yellow'
| null
| undefined

Secondary Radix scale: accepted, unused.

className
string | undefined

Extra class names, added after the module’s own.

FormSummary

The error summary at the top of the form (§10.2): the danger Alert with a heading and a link to each failing field. Render it only while there are errors; it takes focus when it appears.

PropTypeDescription
errors
FormSummaryError[]

The failing fields, each a link that moves focus to its control.

title
React.ReactNode

The heading, in type-subhead: “Fix 2 fields to continue”.

className
string | undefined

Extra class names, added after the module’s own.

FormRow and FormActions

A short pair, such as city and postcode: a 2:1 row that stacks when both fields do not fit, and always shares the row from 480 px of form width.

The action row, --size-px-5 below the last field. Author the solid submit (size="lg") last: it shows first and full width at base, and last (rightmost) from --md-n-above [D106].

Additional types

form
type form = form
FormActionsProps

Props for FormActions: div props.

type FormActionsProps = React.ComponentPropsWithRef<'div'>
FormProps

Props for Form: Base UI Form props plus busy and the color axes.

type FormProps<Values extends Record<string, unknown> = Record<string, unknown>> = {
  /** Extra class names, added after the module's own. */
  className?: string;
  /**
   * While the submission is pending: the form is `inert` and
   * `aria-busy`. Give the solid submit its "-ing…" label ("Sending…").
   */
  busy?: boolean;
  /** Primary Radix scale: structure and inherited control roles. Never defaulted [D133]. */
  primary?:
    | 'ruby'
    | 'olive'
    | 'sage'
    | 'slate'
    | 'sand'
    | 'gray'
    | 'mauve'
    | 'brown'
    | 'bronze'
    | 'gold'
    | 'red'
    | 'crimson'
    | 'tomato'
    | 'pink'
    | 'plum'
    | 'indigo'
    | 'iris'
    | 'violet'
    | 'purple'
    | null;
  /** Secondary Radix scale: accepted, unused. */
  secondary?:
    | 'ruby'
    | 'olive'
    | 'sage'
    | 'slate'
    | 'sand'
    | 'gray'
    | 'mauve'
    | 'brown'
    | 'bronze'
    | 'gold'
    | 'red'
    | 'crimson'
    | 'tomato'
    | 'pink'
    | 'plum'
    | 'indigo'
    | 'iris'
    | 'violet'
    | 'purple'
    | 'amber'
    | 'blue'
    | 'cyan'
    | 'grass'
    | 'green'
    | 'jade'
    | 'lime'
    | 'mint'
    | 'orange'
    | 'sky'
    | 'teal'
    | 'yellow'
    | null;
}
FormRowProps

Props for FormRow: div props.

type FormRowProps = React.ComponentPropsWithRef<'div'>
FormSummaryError

One failing field in the summary: the field’s id and the message that links to it.

type FormSummaryError = {
  /** The `id` of the field's control; the link moves focus there. */
  id: string;
  /** The field's error message, as at the field. */
  message: React.ReactNode;
}
FormSummaryProps

Props for FormSummary.

type FormSummaryProps = {
  /** The heading, in `type-subhead`: "Fix 2 fields to continue". */
  title: React.ReactNode;
  /** The failing fields, each a link that moves focus to its control. */
  errors: FormSummaryError[];
  /** Extra class names, added after the module's own. */
  className?: string;
}

Specification: DESIGN-SYSTEM.md §10.2 (field, fieldset and form) and §5.10.2 (the form's container thresholds).