Skip to contents

Forms

A guide to building forms with Base UI components.

Base UI form control components extend the native constraint validation API so you can build forms for collecting user input or providing control over an interface. Their controlled values and validation state can be integrated with form libraries. The upstream React Hook Form and TanStack Form examples are adapted below for Solid 2.0.

Must be 3 or more characters long

Enter a registry URL with optional tags

Server type
Scaling threshold
20% – 80%
Storage type
Allowed network protocols
import { For } from 'solid-js';
import type { JSX } from '@solidjs/web';
import { Button } from './button';
import { CheckboxGroup } from './checkbox-group';
import { Form } from './form';
import { RadioGroup } from './radio-group';
import { ToastProvider, useToastManager } from './toast';
import * as Autocomplete from './autocomplete';
import * as Checkbox from './checkbox';
import * as Combobox from './combobox';
import * as Field from './field';
import * as Fieldset from './fieldset';
import * as NumberField from './number-field';
import * as Radio from './radio';
import * as Select from './select';
import * as Slider from './slider';
import * as Switch from './switch';

function ExampleForm() {
  const toastManager = useToastManager();
  return (
    <Form
      aria-label="Launch new cloud server"
      onFormSubmit={(formValues) => {
        toastManager.add({
          title: 'Form submitted',
          description: 'The form contains these values:',
          data: formValues,
        });
      }}
    >
      <Field.Root name="serverName">
        <Field.Label>Server name</Field.Label>
        <Field.Control
          defaultValue=""
          placeholder="e.g. api-server-01"
          required
          minlength={3}
          pattern=".*[A-Za-z].*"
        />
        <Field.Description>Must be 3 or more characters long</Field.Description>
        <Field.Error />
      </Field.Root>

      <Field.Root name="region">
        <Combobox.Root items={REGIONS} required>
          <div class="relative text-sm leading-5 font-bold text-neutral-950 dark:text-white">
            <Field.Label class="mb-1 block">Region</Field.Label>
            <Combobox.InputGroup>
              <Combobox.Input placeholder="e.g. eu-central-1" />
              <div class="absolute right-0 bottom-0 inline-flex h-full items-center justify-center text-neutral-500 dark:text-neutral-400">
                <Combobox.Clear />
                <Combobox.Trigger>
                  <Combobox.CaretDownIcon />
                </Combobox.Trigger>
              </div>
            </Combobox.InputGroup>
          </div>
          <Combobox.Portal>
            <Combobox.Positioner>
              <Combobox.Popup>
                <Combobox.Empty>No matches</Combobox.Empty>
                <Combobox.List>
                  {(region: string) => {
                    return (
                      <Combobox.Item value={region}>
                        <Combobox.ItemIndicator>
                          <CheckIcon />
                        </Combobox.ItemIndicator>
                        <span class="col-start-2">{region}</span>
                      </Combobox.Item>
                    );
                  }}
                </Combobox.List>
              </Combobox.Popup>
            </Combobox.Positioner>
          </Combobox.Portal>
        </Combobox.Root>
        <Field.Error />
      </Field.Root>

      <Field.Root name="containerImage">
        <Autocomplete.Root
          items={IMAGES}
          mode="both"
          itemToStringValue={(itemValue: Image) => itemValue.url}
          required
        >
          <Field.Label>Container image</Field.Label>
          <Autocomplete.Input placeholder="e.g. docker.io/library/node:latest" />
          <Field.Description>Enter a registry URL with optional tags</Field.Description>
          <Autocomplete.Portal>
            <Autocomplete.Positioner>
              <Autocomplete.Popup>
                <Autocomplete.List>
                  {(image: Image) => {
                    return (
                      <Autocomplete.Item value={image}>
                        <span>{image.name}</span>
                        <span class="font-mono whitespace-nowrap text-xs opacity-80">
                          {image.url}
                        </span>
                      </Autocomplete.Item>
                    );
                  }}
                </Autocomplete.List>
              </Autocomplete.Popup>
            </Autocomplete.Positioner>
          </Autocomplete.Portal>
        </Autocomplete.Root>
        <Field.Error />
      </Field.Root>

      <Field.Root name="serverType">
        <Select.Root items={SERVER_TYPES} required>
          <div class="w-fit space-y-1">
            <Select.Label>Server type</Select.Label>
            <Select.Trigger class="w-48">
              <Select.Value />
              <Select.Icon>
                <CaretUpDownIcon />
              </Select.Icon>
            </Select.Trigger>
          </div>
          <Select.Portal>
            <Select.Positioner>
              <Select.Popup>
                <Select.ScrollUpArrow />
                <Select.List>
                  <For each={SERVER_TYPES}>
                    {({ label, value }) => {
                      return (
                        <Select.Item value={value}>
                          <Select.ItemIndicator>
                            <CheckIcon />
                          </Select.ItemIndicator>
                          <Select.ItemText>{label}</Select.ItemText>
                        </Select.Item>
                      );
                    }}
                  </For>
                </Select.List>
                <Select.ScrollDownArrow />
              </Select.Popup>
            </Select.Positioner>
          </Select.Portal>
        </Select.Root>
        <Field.Error />
      </Field.Root>

      <Field.Root name="numOfInstances">
        <NumberField.Root defaultValue={undefined} min={1} max={64} required>
          <Field.Label>Number of instances</Field.Label>
          <NumberField.Group>
            <NumberField.Decrement>
              <MinusIcon />
            </NumberField.Decrement>
            <NumberField.Input />
            <NumberField.Increment>
              <PlusIcon />
            </NumberField.Increment>
          </NumberField.Group>
        </NumberField.Root>
        <Field.Error />
      </Field.Root>

      <Field.Root name="scalingThreshold">
        <Fieldset.Root
          render={(renderProps) => (
            <Slider.Root
              defaultValue={[0.2, 0.8]}
              thumbAlignment="edge"
              min={0}
              max={1}
              step={0.01}
              format={{
                style: 'percent',
                minimumFractionDigits: 0,
                maximumFractionDigits: 0,
              }}
              class="w-full gap-y-2"
              {...(renderProps as any)}
            />
          )}
        >
          <Fieldset.Legend>Scaling threshold</Fieldset.Legend>
          <Slider.Value class="col-start-2 text-end" />
          <Slider.Control>
            <Slider.Track>
              <Slider.Indicator />
              <Slider.Thumb index={0} aria-label="Minimum threshold" />
              <Slider.Thumb index={1} aria-label="Maximum threshold" />
            </Slider.Track>
          </Slider.Control>
        </Fieldset.Root>
      </Field.Root>

      <Field.Root name="storageType">
        <Fieldset.Root
          render={(renderProps) => (
            <RadioGroup<'ssd' | 'hdd'> class="gap-4" defaultValue="ssd" {...(renderProps as any)} />
          )}
        >
          <Fieldset.Legend class="-mt-px">Storage type</Fieldset.Legend>
          <Field.Item>
            <Field.Label>
              <Radio.Root value="ssd">
                <Radio.Indicator />
              </Radio.Root>
              SSD
            </Field.Label>
          </Field.Item>
          <Field.Item>
            <Field.Label>
              <Radio.Root value="hdd">
                <Radio.Indicator />
              </Radio.Root>
              HDD
            </Field.Label>
          </Field.Item>
        </Fieldset.Root>
      </Field.Root>

      <Field.Root name="restartOnFailure">
        <Field.Label class="gap-2">
          Restart on failure
          <Switch.Root defaultChecked>
            <Switch.Thumb />
          </Switch.Root>
        </Field.Label>
      </Field.Root>

      <Field.Root name="allowedNetworkProtocols">
        <Fieldset.Root
          render={(renderProps) => <CheckboxGroup defaultValue={[]} {...(renderProps as any)} />}
        >
          <Fieldset.Legend class="mb-2">Allowed network protocols</Fieldset.Legend>
          <div class="flex gap-4">
            <For each={['http', 'https', 'ssh']}>
              {(val) => {
                return (
                  <Field.Item>
                    <Field.Label class="uppercase">
                      <Checkbox.Root value={val}>
                        <Checkbox.Indicator>
                          <CheckIcon />
                        </Checkbox.Indicator>
                      </Checkbox.Root>
                      {val}
                    </Field.Label>
                  </Field.Item>
                );
              }}
            </For>
          </div>
        </Fieldset.Root>
      </Field.Root>

      <Button type="submit" class="mt-3">
        Launch server
      </Button>
    </Form>
  );
}

export default function App() {
  return (
    <ToastProvider>
      <ExampleForm />
    </ToastProvider>
  );
}

function CaretUpDownIcon(props: JSX.IntrinsicElements['svg']) {
  return (
    <svg
      width="16"
      height="16"
      viewBox="0 0 16 16"
      fill="currentColor"
      {...props}
      style={props.style}
    >
      <path d="M11 10H5l3 3.5zm0-4H5l3-3.5z" />
    </svg>
  );
}

function CheckIcon(props: JSX.IntrinsicElements['svg']) {
  return (
    <svg
      width="16"
      height="16"
      viewBox="0 0 16 16"
      fill="none"
      stroke="currentColor"
      {...props}
      style={props.style}
    >
      <path d="m2.5 8.5 4 4 7-9" />
    </svg>
  );
}

function PlusIcon(props: JSX.IntrinsicElements['svg']) {
  return (
    <svg
      width="16"
      height="16"
      viewBox="0 0 16 16"
      fill="none"
      stroke="currentColor"
      stroke-linecap="square"
      stroke-linejoin="round"
      {...props}
      style={props.style}
    >
      <path d="M1.5 8h13M8 14.5v-13" />
    </svg>
  );
}

function MinusIcon(props: JSX.IntrinsicElements['svg']) {
  return (
    <svg
      width="16"
      height="16"
      viewBox="0 0 16 16"
      fill="none"
      stroke="currentColor"
      stroke-linecap="square"
      stroke-linejoin="round"
      {...props}
      style={props.style}
    >
      <path d="M1.5 8h13" />
    </svg>
  );
}

function cartesian<T extends string[][]>(...arrays: T): string[][] {
  return arrays.reduce<string[][]>(
    (acc, curr) => acc.flatMap((a) => curr.map((b) => [...a, b])),
    [[]],
  );
}

const REGIONS = cartesian(['us', 'eu', 'ap'], ['central', 'east', 'west'], ['1', '2', '3']).map(
  (part) => part.join('-'),
);

interface Image {
  url: string;
  name: string;
}
/* prettier-ignore */
const IMAGES: Image[] = ['nginx:1.29-alpine', 'node:22-slim', 'postgres:18', 'redis:8.2.2-alpine'].map((name) => ({
  url: `docker.io/library/${name}`,
  name,
}));

const SERVER_TYPES = [
  { label: 'Select server type', value: null },
  ...cartesian(['t', 'm'], ['1', '2'], ['small', 'medium', 'large']).map((part) => {
    const value = part.join('.').replace('.', '');
    return { label: value, value };
  }),
];

Naming form controls

Form controls must have an accessible name in order to be recognized by assistive technologies. Use the label strategy below for each control type.

Input controls

Use <Field.Label> or a native <label> to label the following controls:

  • Input
  • NumberField
  • OTPField
  • Autocomplete
  • Combobox (input outside popup)
  • Checkbox
  • Radio
  • Switch

You can implicitly label <Checkbox>, <Radio> and <Switch> components by enclosing them with <Field.Label>:

Implicitly labeling a switch
import { Field } from 'base-ui-solid/field';
import { Switch } from 'base-ui-solid/switch';

<Field.Root>
  <Field.Label>
    <Switch.Root />
    Developer mode
  </Field.Label>
  <Field.Description>Enables extra tools for web developers</Field.Description>
</Field.Root>;

Trigger-based controls

  • Combobox (input inside popup): use <Combobox.Label>.
  • Select: use <Select.Label>.
  • Slider: use <Slider.Label>. For multi-thumb sliders, also add an aria-label on each <Slider.Thumb> to distinguish the thumbs.

Fallback

If no visible label is rendered, provide aria-label on the actual form control.

Describing the control

<Field.Description> automatically assigns an accessible description:

Labeling select and slider
import { Form } from 'base-ui-solid/form';
import { Field } from 'base-ui-solid/field';
import { Select } from 'base-ui-solid/select';
import { Slider } from 'base-ui-solid/slider';

<Form>
  <Field.Root>
    <Select.Root>
      <Select.Label>Time zone</Select.Label>
      <Select.Trigger />
    </Select.Root>
    <Field.Description>Used for notifications and reminders</Field.Description>
  </Field.Root>

  <Field.Root>
    <Slider.Root defaultValue={50}>
      <Slider.Label>Zoom level</Slider.Label>
      <Field.Description>Adjust the size of the user interface</Field.Description>
      <Slider.Control>
        <Slider.Track>
          <Slider.Thumb />
        </Slider.Track>
      </Slider.Control>
    </Slider.Root>
  </Field.Root>
</Form>;

Labeling control groups

Compose <Fieldset> when a single label applies to multiple controls, such as a range slider with multiple thumbs or a section that combines several inputs. For checkbox and radio groups, keep the group label in <Fieldset.Legend> and wrap each option with <Field.Item>:

Composing range slider and radio group with fieldset
import { Form } from 'base-ui-solid/form';
import { Field } from 'base-ui-solid/field';
import { Fieldset } from 'base-ui-solid/fieldset';
import { Radio } from 'base-ui-solid/radio';
import { RadioGroup } from 'base-ui-solid/radio-group';
import { Slider } from 'base-ui-solid/slider';

<Form>
  <Field.Root>
    <Fieldset.Root render={(props) => <Slider.Root {...props} />}>
      <Fieldset.Legend>Price range</Fieldset.Legend>
      <Slider.Control>
        <Slider.Track>
          <Slider.Thumb aria-label="Minimum price" />
          <Slider.Thumb aria-label="Maximum price" />
        </Slider.Track>
      </Slider.Control>
    </Fieldset.Root>
  </Field.Root>

  <Field.Root>
    <Fieldset.Root render={(props) => <RadioGroup {...props} />}>
      <Fieldset.Legend>Storage type</Fieldset.Legend>
      <Radio.Root value="ssd" />
      <Radio.Root value="hdd" />
    </Fieldset.Root>
  </Field.Root>
</Form>;

<Field.Item> should enclose each checkbox or radio option so every control has its own label and description:

Explicitly labeling checkboxes in a checkbox group
import { Form } from 'base-ui-solid/form';
import { Field } from 'base-ui-solid/field';
import { Fieldset } from 'base-ui-solid/fieldset';
import { Checkbox } from 'base-ui-solid/checkbox';
import { CheckboxGroup } from 'base-ui-solid/checkbox-group';

<Field.Root>
  <Fieldset.Root render={(props) => <CheckboxGroup {...props} />}>
    <Fieldset.Legend>Backup schedule</Fieldset.Legend>
    <Field.Item>
      <Checkbox.Root value="daily" />
      <Field.Label>Daily</Field.Label>
      <Field.Description>Daily at 00:00</Field.Description>
    </Field.Item>
    <Field.Item>
      <Checkbox.Root value="monthly" />
      <Field.Label>Monthly</Field.Label>
      <Field.Description>On the 5th of every month at 23:59</Field.Description>
    </Field.Item>
  </Fieldset.Root>
</Field.Root>;

Building form fields

Pass the name prop to <Field.Root> to include the wrapped control’s value when a parent form is submitted:

Assigning field name to combobox
import { Form } from 'base-ui-solid/form';
import { Field } from 'base-ui-solid/field';
import { Combobox } from 'base-ui-solid/combobox';

<Form>
  <Field.Root name="country">
    <Field.Label>Country of residence</Field.Label>
    <Combobox.Root />
  </Field.Root>
</Form>;

Submitting data

You can take over form submission using the native onSubmit, or custom onFormSubmit props:

Native submission using onSubmit
import { Form } from 'base-ui-solid/form';

<Form
  onSubmit={async (event) => {
    // Prevent the browser's default full-page refresh
    event.preventDefault();
    // Create a FormData object
    const formData = new FormData(event.currentTarget);
    // Send the FormData instance in a fetch request
    await fetch('https://api.example.com', {
      method: 'POST',
      body: formData,
    });
  }}
/>;

When using onFormSubmit, you receive form values as a JavaScript object, with eventDetails provided as a second argument. Additionally, preventDefault() is automatically called on the native submit event:

Submission using onFormSubmit
import { Form } from 'base-ui-solid/form';

<Form
  onFormSubmit={async (formValues) => {
    const payload = {
      product_id: formValues.id,
      order_quantity: formValues.quantity,
    };
    await fetch('https://api.example.com', {
      method: 'POST',
      body: JSON.stringify(payload),
    });
  }}
/>;

Constraint validation

Base UI form components support native HTML validation attributes for many validation rules:

  • required specifies a required field.
  • minlength and maxlength specify a valid length for text fields.
  • pattern specifies a regular expression that the field value must match.
  • step specifies an increment that numeric field values must be an integral multiple of.
Defining constraint validation on a text field
import { Field } from 'base-ui-solid/field';

<Field.Root name="website">
  <Field.Control type="url" required pattern="https?://.*" />
  <Field.Error />
</Field.Root>;

Base UI form components use a hidden input to participate in native form submission and validation. To anchor the hidden input near a control so the native validation bubble points to the correct area, ensure the component has been given a name, and wrap controls in a relatively positioned container for best results.

Positioning hidden inputs
import { Field } from 'base-ui-solid/field';
import { Select } from 'base-ui-solid/select';

<Field.Root name="apple">
  <Select.Root>
    <Select.Label>Apple</Select.Label>
    <div class="relative">
      <Select.Trigger />
    </div>
  </Select.Root>
</Field.Root>;

Custom validation

You can add custom validation logic by passing a synchronous or asynchronous validation function to the validate prop, which runs after native validations have passed.

Use the validationMode prop to configure when validation is performed:

  • onSubmit (default) validates all fields when the containing <Form> is submitted, afterwards invalid fields revalidate when their value changes.
  • onFocusOut validates the field when focus moves away.
  • onChange validates the field when the value changes, for example, after each keypress in a text field or when a checkbox is checked or unchecked.

validationDebounceTime can be used to debounce the function in use cases such as asynchronous requests or text fields that validate onChange.

Text input using custom asynchronous validation
import { Field } from 'base-ui-solid/field';

<Field.Root
  name="username"
  validationMode="onChange"
  validationDebounceTime={300}
  validate={async (value) => {
    if (value === 'admin') {
      /* return an error message when invalid */
      return 'Reserved for system use.';
    }

    const result = await fetch(
      {/* prettier-ignore */},
      /* check the availability of a username from an external API */
    );

    if (!result) {
      return `${value} is unavailable.`;
    }

    /* return `null` when valid */
    return null;
  }}
>
  <Field.Control required minlength={3} />
  <Field.Error />
</Field.Root>;

Server-side validation

You can pass errors returned by (post-submission) server-side validation to the errors prop, which will be merged into the client-side field state for display.

This should be an object with field names as keys, and an error string or array of strings as the value. Once a field’s value changes, any corresponding error in errors will be cleared from the field state.

Displaying errors returned by server-side validation
import { Form } from 'base-ui-solid/form';
import { Field } from 'base-ui-solid/field';

async function submitToServer(/* payload */) {
  return {
    errors: {
      promoCode: 'This promo code has expired',
    },
  };
}

const [errors, setErrors] = createSignal();

<Form
  errors={errors()}
  onSubmit={async (event) => {
    event.preventDefault();
    const response = await submitToServer(/* data */);
    setErrors(response.errors);
  }}
>
  <Field.Root name="promoCode" />
</Form>;

To get errors from the server, use an async submit handler (or the server-function integration provided by your Solid framework) and pass the returned field errors to errors.

Returning server errors in Solid
import { createSignal } from 'solid-js';
import { Form } from 'base-ui-solid/form';
import { Field } from 'base-ui-solid/field';
import { login } from './actions';

function LoginForm() {
  const [errors, setErrors] = createSignal({});
  return (
    <Form
      errors={errors()}
      onSubmit={async (event) => {
        event.preventDefault();
        const result = await login(new FormData(event.currentTarget));
        setErrors(result.errors ?? {});
      }}
    >
      <Field.Root name="password">
        <Field.Control type="password" />
        <Field.Error />
      </Field.Root>
    </Form>
  );
}

Displaying errors

Use <Field.Error> without children to automatically display the field’s native error message when invalid. The match prop can be used to customize the message based on the validity state, and manage internationalization from your application logic:

Customizing error message for a required field
<Field.Error match="valueMissing">You must create a username</Field.Error>

React Hook Form

React Hook Form is a React library, but the same integration pattern works with any controlled-form solution in Solid. The demo below uses a small Solid helper (included in its source) that manages values, validation, touched and dirty flags, and submission, and wires them to Base UI’s Field props.

Must be 3 or more characters long

Enter a registry URL with optional tags

Server type
Scaling threshold
20% – 80%
Storage type
Allowed network protocols
import { For } from 'solid-js';
// the same controlled values, validation, touched/dirty flags, and submission.
import type { JSX } from '@solidjs/web';
import { createControlledForm, Controller } from './solid-controller';
import { Button } from './button';
import { CheckboxGroup } from './checkbox-group';
import { Form } from './form';
import { RadioGroup } from './radio-group';
import { ToastProvider, useToastManager } from './toast';
import * as Autocomplete from './autocomplete';
import * as Checkbox from './checkbox';
import * as Combobox from './combobox';
import * as Field from './field';
import * as Fieldset from './fieldset';
import * as NumberField from './number-field';
import * as Radio from './radio';
import * as Select from './select';
import * as Slider from './slider';
import * as Switch from './switch';

interface FormValues {
  serverName: string;
  region: string | null;
  containerImage: string;
  serverType: string | null;
  numOfInstances: number | null;
  scalingThreshold: number[];
  storageType: 'ssd' | 'hdd';
  restartOnFailure: boolean;
  allowedNetworkProtocols: string[];
}

function ControlledForm() {
  const toastManager = useToastManager();

  const { control, handleSubmit } = createControlledForm<FormValues>({
    defaultValues: {
      serverName: '',
      region: null,
      containerImage: '',
      serverType: null,
      numOfInstances: null,
      scalingThreshold: [0.2, 0.8],
      storageType: 'ssd',
      restartOnFailure: true,
      allowedNetworkProtocols: [],
    },
  });

  function submitForm(data: FormValues) {
    toastManager.add({
      title: 'Form submitted',
      description: 'The form contains these values:',
      data,
    });
  }

  return (
    <Form aria-label="Launch new cloud server" onSubmit={handleSubmit(submitForm)}>
      <Controller
        name="serverName"
        control={control}
        rules={{
          required: 'This field is required.',
          minlength: { value: 3, message: 'At least 3 characters.' },
        }}
        render={(model) => (
          <Field.Root
            name={model.field.name}
            invalid={model.fieldState.invalid}
            touched={model.fieldState.isTouched}
            dirty={model.fieldState.isDirty}
          >
            <Field.Label>Server name</Field.Label>
            <Field.Control
              ref={model.field.ref}
              value={model.field.value}
              onFocusOut={model.field.onFocusOut}
              onValueChange={model.field.onChange}
              placeholder="e.g. api-server-01"
            />
            <Field.Description>Must be 3 or more characters long</Field.Description>
            <Field.Error match={!!model.fieldState.error}>
              {model.fieldState.error?.message}
            </Field.Error>
          </Field.Root>
        )}
      />

      <Controller
        name="region"
        control={control}
        rules={{
          required: 'This field is required.',
        }}
        render={(model) => (
          <Field.Root
            name={model.field.name}
            invalid={model.fieldState.invalid}
            touched={model.fieldState.isTouched}
            dirty={model.fieldState.isDirty}
          >
            <Combobox.Root
              items={REGIONS}
              value={model.field.value}
              onValueChange={model.field.onChange}
            >
              <div class="relative text-sm leading-5 font-bold text-neutral-950 dark:text-white">
                <Field.Label class="mb-1 block">Region</Field.Label>
                <Combobox.InputGroup>
                  <Combobox.Input
                    placeholder="e.g. eu-central-1"
                    ref={model.field.ref}
                    onFocusOut={model.field.onFocusOut}
                  />
                  <div class="absolute right-0 bottom-0 inline-flex h-full items-center justify-center text-neutral-500 dark:text-neutral-400">
                    <Combobox.Clear />
                    <Combobox.Trigger>
                      <Combobox.CaretDownIcon />
                    </Combobox.Trigger>
                  </div>
                </Combobox.InputGroup>
              </div>
              <Combobox.Portal>
                <Combobox.Positioner>
                  <Combobox.Popup>
                    <Combobox.Empty>No matches</Combobox.Empty>
                    <Combobox.List>
                      {(region: string) => {
                        return (
                          <Combobox.Item value={region}>
                            <Combobox.ItemIndicator>
                              <CheckIcon />
                            </Combobox.ItemIndicator>
                            <span class="col-start-2">{region}</span>
                          </Combobox.Item>
                        );
                      }}
                    </Combobox.List>
                  </Combobox.Popup>
                </Combobox.Positioner>
              </Combobox.Portal>
            </Combobox.Root>
            <Field.Error match={!!model.fieldState.error}>
              {model.fieldState.error?.message}
            </Field.Error>
          </Field.Root>
        )}
      />

      <Controller
        name="containerImage"
        control={control}
        rules={{
          required: 'This field is required.',
        }}
        render={(model) => (
          <Field.Root
            name={model.field.name}
            invalid={model.fieldState.invalid}
            touched={model.fieldState.isTouched}
            dirty={model.fieldState.isDirty}
          >
            <Autocomplete.Root
              items={IMAGES}
              mode="both"
              itemToStringValue={(itemValue: Image) => itemValue.url}
              value={model.field.value}
              onValueChange={model.field.onChange}
            >
              <Field.Label>Container image</Field.Label>
              <Autocomplete.Input
                placeholder="e.g. docker.io/library/node:latest"
                ref={model.field.ref}
                onFocusOut={model.field.onFocusOut}
              />
              <Field.Description>Enter a registry URL with optional tags</Field.Description>
              <Autocomplete.Portal>
                <Autocomplete.Positioner>
                  <Autocomplete.Popup>
                    <Autocomplete.List>
                      {(image: Image) => {
                        return (
                          <Autocomplete.Item value={image}>
                            <span>{image.name}</span>
                            <span class="font-mono whitespace-nowrap text-xs opacity-80">
                              {image.url}
                            </span>
                          </Autocomplete.Item>
                        );
                      }}
                    </Autocomplete.List>
                  </Autocomplete.Popup>
                </Autocomplete.Positioner>
              </Autocomplete.Portal>
            </Autocomplete.Root>
            <Field.Error match={!!model.fieldState.error}>
              {model.fieldState.error?.message}
            </Field.Error>
          </Field.Root>
        )}
      />

      <Controller
        name="serverType"
        control={control}
        rules={{
          required: 'This field is required.',
        }}
        render={(model) => (
          <Field.Root
            name={model.field.name}
            invalid={model.fieldState.invalid}
            touched={model.fieldState.isTouched}
            dirty={model.fieldState.isDirty}
          >
            <Select.Root
              items={SERVER_TYPES}
              value={model.field.value}
              onValueChange={model.field.onChange}
              inputRef={model.field.ref}
            >
              <div class="w-fit space-y-1">
                <Select.Label>Server type</Select.Label>
                <Select.Trigger class="w-48" onFocusOut={model.field.onFocusOut}>
                  <Select.Value />
                  <Select.Icon>
                    <CaretUpDownIcon />
                  </Select.Icon>
                </Select.Trigger>
              </div>
              <Select.Portal>
                <Select.Positioner>
                  <Select.Popup>
                    <Select.ScrollUpArrow />
                    <Select.List>
                      <For each={SERVER_TYPES}>
                        {({ label, value: serverType }) => {
                          return (
                            <Select.Item value={serverType}>
                              <Select.ItemIndicator>
                                <CheckIcon />
                              </Select.ItemIndicator>
                              <Select.ItemText>{label}</Select.ItemText>
                            </Select.Item>
                          );
                        }}
                      </For>
                    </Select.List>
                    <Select.ScrollDownArrow />
                  </Select.Popup>
                </Select.Positioner>
              </Select.Portal>
            </Select.Root>
            <Field.Error match={!!model.fieldState.error}>
              {model.fieldState.error?.message}
            </Field.Error>
          </Field.Root>
        )}
      />

      <Controller
        name="numOfInstances"
        control={control}
        rules={{
          required: 'This field is required.',
        }}
        render={(model) => (
          <Field.Root
            name={model.field.name}
            invalid={model.fieldState.invalid}
            touched={model.fieldState.isTouched}
            dirty={model.fieldState.isDirty}
          >
            <NumberField.Root
              value={model.field.value}
              min={1}
              max={64}
              onValueChange={model.field.onChange}
            >
              <Field.Label>Number of instances</Field.Label>
              <NumberField.Group>
                <NumberField.Decrement>
                  <MinusIcon />
                </NumberField.Decrement>
                <NumberField.Input ref={model.field.ref} onFocusOut={model.field.onFocusOut} />
                <NumberField.Increment>
                  <PlusIcon />
                </NumberField.Increment>
              </NumberField.Group>
            </NumberField.Root>
            <Field.Error match={!!model.fieldState.error}>
              {model.fieldState.error?.message}
            </Field.Error>
          </Field.Root>
        )}
      />

      <Controller
        name="scalingThreshold"
        control={control}
        render={(model) => (
          <Field.Root
            name={model.field.name}
            invalid={model.fieldState.invalid}
            touched={model.fieldState.isTouched}
            dirty={model.fieldState.isDirty}
          >
            <Fieldset.Root
              render={(renderProps) => (
                <Slider.Root
                  value={model.field.value}
                  onValueChange={model.field.onChange}
                  onValueCommitted={model.field.onChange}
                  thumbAlignment="edge"
                  min={0}
                  max={1}
                  step={0.01}
                  format={{
                    style: 'percent',
                    minimumFractionDigits: 0,
                    maximumFractionDigits: 0,
                  }}
                  class="w-full gap-y-2"
                  {...(renderProps as any)}
                />
              )}
            >
              <Fieldset.Legend>Scaling threshold</Fieldset.Legend>
              <Slider.Value class="col-start-2 text-end" />
              <Slider.Control>
                <Slider.Track>
                  <Slider.Indicator />
                  <Slider.Thumb
                    index={0}
                    aria-label="Minimum threshold"
                    onFocusOut={model.field.onFocusOut}
                    inputRef={model.field.ref}
                  />
                  <Slider.Thumb
                    index={1}
                    aria-label="Maximum threshold"
                    onFocusOut={model.field.onFocusOut}
                  />
                </Slider.Track>
              </Slider.Control>
            </Fieldset.Root>
          </Field.Root>
        )}
      />

      <Controller
        name="storageType"
        control={control}
        render={(model) => (
          <Field.Root
            name={model.field.name}
            invalid={model.fieldState.invalid}
            touched={model.fieldState.isTouched}
            dirty={model.fieldState.isDirty}
          >
            <Fieldset.Root
              render={(renderProps) => (
                <RadioGroup
                  class="gap-4"
                  value={model.field.value}
                  onValueChange={model.field.onChange}
                  inputRef={model.field.ref}
                  {...(renderProps as any)}
                />
              )}
            >
              <Fieldset.Legend class="-mt-px">Storage type</Fieldset.Legend>
              <Field.Item>
                <Field.Label>
                  <Radio.Root value="ssd" onFocusOut={model.field.onFocusOut}>
                    <Radio.Indicator />
                  </Radio.Root>
                  SSD
                </Field.Label>
              </Field.Item>
              <Field.Item>
                <Field.Label>
                  <Radio.Root value="hdd" onFocusOut={model.field.onFocusOut}>
                    <Radio.Indicator />
                  </Radio.Root>
                  HDD
                </Field.Label>
              </Field.Item>
            </Fieldset.Root>
          </Field.Root>
        )}
      />

      <Controller
        name="restartOnFailure"
        control={control}
        render={(model) => (
          <Field.Root
            name={model.field.name}
            invalid={model.fieldState.invalid}
            touched={model.fieldState.isTouched}
            dirty={model.fieldState.isDirty}
          >
            <Field.Label class="gap-2">
              Restart on failure
              <Switch.Root
                checked={model.field.value}
                inputRef={model.field.ref}
                onCheckedChange={model.field.onChange}
                onFocusOut={model.field.onFocusOut}
              >
                <Switch.Thumb />
              </Switch.Root>
            </Field.Label>
          </Field.Root>
        )}
      />

      <Controller
        name="allowedNetworkProtocols"
        control={control}
        render={(model) => (
          <Field.Root
            name={model.field.name}
            invalid={model.fieldState.invalid}
            touched={model.fieldState.isTouched}
            dirty={model.fieldState.isDirty}
          >
            <Fieldset.Root
              render={(renderProps) => (
                <CheckboxGroup
                  value={model.field.value}
                  onValueChange={model.field.onChange}
                  {...(renderProps as any)}
                />
              )}
            >
              <Fieldset.Legend class="mb-2">Allowed network protocols</Fieldset.Legend>
              <div class="flex gap-4">
                <For each={['http', 'https', 'ssh']}>
                  {(val) => {
                    return (
                      <Field.Item>
                        <Field.Label class="uppercase">
                          <Checkbox.Root
                            value={val}
                            inputRef={val === 'http' ? model.field.ref : undefined}
                            onFocusOut={model.field.onFocusOut}
                          >
                            <Checkbox.Indicator>
                              <CheckIcon />
                            </Checkbox.Indicator>
                          </Checkbox.Root>
                          {val}
                        </Field.Label>
                      </Field.Item>
                    );
                  }}
                </For>
              </div>
            </Fieldset.Root>
          </Field.Root>
        )}
      />

      <Button type="submit" class="mt-3">
        Launch server
      </Button>
    </Form>
  );
}

export default function App() {
  return (
    <ToastProvider>
      <ControlledForm />
    </ToastProvider>
  );
}

function CaretUpDownIcon(props: JSX.IntrinsicElements['svg']) {
  return (
    <svg
      width="16"
      height="16"
      viewBox="0 0 16 16"
      fill="currentColor"
      {...props}
      style={props.style}
    >
      <path d="M11 10H5l3 3.5zm0-4H5l3-3.5z" />
    </svg>
  );
}

function CheckIcon(props: JSX.IntrinsicElements['svg']) {
  return (
    <svg
      width="16"
      height="16"
      viewBox="0 0 16 16"
      fill="none"
      stroke="currentColor"
      {...props}
      style={props.style}
    >
      <path d="m2.5 8.5 4 4 7-9" />
    </svg>
  );
}

function PlusIcon(props: JSX.IntrinsicElements['svg']) {
  return (
    <svg
      width="16"
      height="16"
      viewBox="0 0 16 16"
      fill="none"
      stroke="currentColor"
      stroke-linecap="square"
      stroke-linejoin="round"
      {...props}
      style={props.style}
    >
      <path d="M1.5 8h13M8 14.5v-13" />
    </svg>
  );
}

function MinusIcon(props: JSX.IntrinsicElements['svg']) {
  return (
    <svg
      width="16"
      height="16"
      viewBox="0 0 16 16"
      fill="none"
      stroke="currentColor"
      stroke-linecap="square"
      stroke-linejoin="round"
      {...props}
      style={props.style}
    >
      <path d="M1.5 8h13" />
    </svg>
  );
}

function cartesian<T extends string[][]>(...arrays: T): string[][] {
  return arrays.reduce<string[][]>(
    (acc, curr) => acc.flatMap((a) => curr.map((b) => [...a, b])),
    [[]],
  );
}

const REGIONS = cartesian(['us', 'eu', 'ap'], ['central', 'east', 'west'], ['1', '2', '3']).map(
  (part) => part.join('-'),
);

interface Image {
  url: string;
  name: string;
}
/* prettier-ignore */
const IMAGES: Image[] = ['nginx:1.29-alpine', 'node:22-slim', 'postgres:18', 'redis:8.2.2-alpine'].map((name) => ({
  url: `docker.io/library/${name}`,
  name,
}));

const SERVER_TYPES = [
  { label: 'Select server type', value: null },
  ...cartesian(['t', 'm'], ['1', '2'], ['small', 'medium', 'large']).map((part) => {
    const value = part.join('.').replace('.', '');
    return { label: value, value };
  }),
];

Initialize the form

Create a controller with initial values for each field. This example uses the local Solid controller included in the demo source.

Initialize a form instance
import { createControlledForm } from './solid-controller';

const form = createControlledForm({
  defaultValues: { username: '', email: '' },
});

Integrate components

Forward the controller’s reactive field values and state to Base UI. Keep the object intact so its getters remain reactive. Forward ref to the input so the controller can focus invalid fields.

Integrating a controlled field with Base UI components
import { Controller } from './solid-controller';
import { Field } from 'base-ui-solid/field';

<Controller
  name="username"
  control={form.control}
  render={(model) => (
    <Field.Root
      name={model.field.name}
      invalid={model.fieldState.invalid}
      touched={model.fieldState.isTouched}
      dirty={model.fieldState.isDirty}
    >
      <Field.Label>Username</Field.Label>
      <Field.Control
        value={model.field.value}
        onValueChange={model.field.onChange}
        onFocusOut={model.field.onFocusOut}
        ref={model.field.ref}
      />
      <Field.Error match={model.fieldState.invalid}>{model.fieldState.error?.message}</Field.Error>
    </Field.Root>
  )}
/>;

Field validation

Assign validation rules to the controller. The example controller validates on submission and revalidates when an invalid field changes.

Defining validation rules and displaying errors
<Controller
  name="username"
  control={form.control}
  rules={{
    required: 'This is a required field',
    minlength: { value: 2, message: 'Too short' },
  }}
  render={(model) => (
    <Field.Root name={model.field.name} invalid={model.fieldState.invalid}>
      <Field.Control value={model.field.value} onValueChange={model.field.onChange} />
      <Field.Error match={model.fieldState.invalid}>{model.fieldState.error?.message}</Field.Error>
    </Field.Root>
  )}
/>

Submitting data

Wrap a submit handler with handleSubmit to validate the fields before receiving the form values.

Form submission handler
import { Form } from 'base-ui-solid/form';

<Form
  onSubmit={form.handleSubmit(async (values) => {
    await fetch('/api/account', { method: 'POST', body: JSON.stringify(values) });
  })}
/>;

TanStack Form

TanStack Form’s Solid adapter (@tanstack/solid-form) targets Solid 1.x. The demo below shows the same integration with a small Solid 2.0 helper (included in its source) that mirrors TanStack Form’s field API and validation flow, so it can be swapped for the adapter once that supports Solid 2.0.

Must be 3 or more characters long

Enter a registry URL with optional tags

Server type
Scaling threshold
20% – 80%
Storage type
Allowed network protocols
import { For } from 'solid-js';
// Solid controller preserves this example's field API and validation flow.
import type { JSX } from '@solidjs/web';
import { createControlledForm } from './solid-controller';
import { Button } from './button';
import { CheckboxGroup } from './checkbox-group';
import { RadioGroup } from './radio-group';
import { ToastProvider, useToastManager } from './toast';
import * as Autocomplete from './autocomplete';
import * as Checkbox from './checkbox';
import * as Combobox from './combobox';
import * as Field from './field';
import * as Fieldset from './fieldset';
import * as NumberField from './number-field';
import * as Radio from './radio';
import * as Select from './select';
import * as Slider from './slider';
import * as Switch from './switch';

type DeepKeys<T> = keyof T;
type ValidationError = string;

interface FormValues {
  serverName: string;
  region: string | null;
  containerImage: string;
  serverType: string | null;
  numOfInstances: number | null;
  scalingThreshold: number[];
  storageType: 'ssd' | 'hdd';
  restartOnFailure: boolean;
  allowedNetworkProtocols: string[];
}

const defaultValues: FormValues = {
  serverName: '',
  region: null,
  containerImage: '',
  serverType: null,
  numOfInstances: null,
  scalingThreshold: [0.2, 0.8],
  storageType: 'ssd',
  restartOnFailure: true,
  allowedNetworkProtocols: [],
};

function TanstackForm() {
  const toastManager = useToastManager();

  const form = createControlledForm({
    defaultValues,
    onSubmit: ({ value: formValues }) => {
      toastManager.add({
        title: 'Form submitted',
        description: 'The form contains these values:',
        data: formValues,
      });
    },
    validators: {
      onDynamic: ({ value: formValues }) => {
        const errors: Partial<Record<DeepKeys<FormValues>, ValidationError>> = {};

        (
          ['serverName', 'region', 'containerImage', 'serverType', 'numOfInstances'] as const
        ).forEach((requiredField) => {
          if (!formValues[requiredField]) {
            errors[requiredField] = 'This is a required field.';
          }
        });

        if (formValues.serverName && formValues.serverName.length < 3) {
          errors.serverName = 'At least 3 characters.';
        }

        return isEmpty(errors) ? undefined : { form: errors, fields: errors };
      },
    },
  });

  return (
    <form
      aria-label="Launch new cloud server"
      class="flex w-full max-w-3xs flex-col gap-5 sm:max-w-[20rem]"
      novalidate
      onSubmit={(event) => {
        event.preventDefault();
        form.handleSubmitForm();
      }}
    >
      <form.Field
        name="serverName"
        children={(field) => {
          return (
            <Field.Root
              name={field.name}
              invalid={!field.state.meta.isValid}
              dirty={field.state.meta.isDirty}
              touched={field.state.meta.isTouched}
            >
              <Field.Label>Server name</Field.Label>
              <Field.Control
                value={field.state.value}
                onValueChange={field.handleChange}
                onFocusOut={field.handleBlur}
                placeholder="e.g. api-server-01"
              />
              <Field.Description>Must be 3 or more characters long</Field.Description>
              <Field.Error match={!field.state.meta.isValid}>
                {field.state.meta.errors.join(',')}
              </Field.Error>
            </Field.Root>
          );
        }}
      />

      <form.Field
        name="region"
        children={(field) => {
          return (
            <Field.Root
              name={field.name}
              invalid={!field.state.meta.isValid}
              dirty={field.state.meta.isDirty}
              touched={field.state.meta.isTouched}
            >
              <Combobox.Root
                items={REGIONS}
                value={field.state.value}
                onValueChange={field.handleChange}
              >
                <div class="relative text-sm leading-5 font-bold text-neutral-950 dark:text-white">
                  <Field.Label class="mb-1 block">Region</Field.Label>
                  <Combobox.InputGroup>
                    <Combobox.Input placeholder="e.g. eu-central-1" onFocusOut={field.handleBlur} />
                    <div class="absolute right-0 bottom-0 inline-flex h-full items-center justify-center text-neutral-500 dark:text-neutral-400">
                      <Combobox.Clear />
                      <Combobox.Trigger>
                        <Combobox.CaretDownIcon />
                      </Combobox.Trigger>
                    </div>
                  </Combobox.InputGroup>
                </div>
                <Combobox.Portal>
                  <Combobox.Positioner>
                    <Combobox.Popup>
                      <Combobox.Empty>No matches</Combobox.Empty>
                      <Combobox.List>
                        {(region: string) => {
                          return (
                            <Combobox.Item value={region}>
                              <Combobox.ItemIndicator>
                                <CheckIcon />
                              </Combobox.ItemIndicator>
                              <span class="col-start-2">{region}</span>
                            </Combobox.Item>
                          );
                        }}
                      </Combobox.List>
                    </Combobox.Popup>
                  </Combobox.Positioner>
                </Combobox.Portal>
              </Combobox.Root>

              <Field.Error match={!field.state.meta.isValid}>
                {field.state.meta.errors.join(',')}
              </Field.Error>
            </Field.Root>
          );
        }}
      />

      <form.Field
        name="containerImage"
        children={(field) => {
          return (
            <Field.Root
              name={field.name}
              invalid={!field.state.meta.isValid}
              dirty={field.state.meta.isDirty}
              touched={field.state.meta.isTouched}
            >
              <Autocomplete.Root
                items={IMAGES}
                mode="both"
                value={field.state.value}
                onValueChange={field.handleChange}
                itemToStringValue={(itemValue: Image) => itemValue.url}
              >
                <Field.Label>Container image</Field.Label>
                <Autocomplete.Input
                  placeholder="e.g. docker.io/library/node:latest"
                  onFocusOut={field.handleBlur}
                />
                <Field.Description>Enter a registry URL with optional tags</Field.Description>
                <Autocomplete.Portal>
                  <Autocomplete.Positioner>
                    <Autocomplete.Popup>
                      <Autocomplete.List>
                        {(image: Image) => {
                          return (
                            <Autocomplete.Item value={image}>
                              <span>{image.name}</span>
                              <span class="font-mono whitespace-nowrap text-xs opacity-80">
                                {image.url}
                              </span>
                            </Autocomplete.Item>
                          );
                        }}
                      </Autocomplete.List>
                    </Autocomplete.Popup>
                  </Autocomplete.Positioner>
                </Autocomplete.Portal>
              </Autocomplete.Root>
              <Field.Error match={!field.state.meta.isValid}>
                {field.state.meta.errors.join(',')}
              </Field.Error>
            </Field.Root>
          );
        }}
      />

      <form.Field
        name="serverType"
        children={(field) => {
          return (
            <Field.Root
              name={field.name}
              invalid={!field.state.meta.isValid}
              dirty={field.state.meta.isDirty}
              touched={field.state.meta.isTouched}
            >
              <Select.Root
                items={SERVER_TYPES}
                value={field.state.value}
                onValueChange={field.handleChange}
              >
                <div class="w-fit space-y-1">
                  <Select.Label>Server type</Select.Label>
                  <Select.Trigger class="w-48" onFocusOut={field.handleBlur}>
                    <Select.Value />
                    <Select.Icon>
                      <CaretUpDownIcon />
                    </Select.Icon>
                  </Select.Trigger>
                </div>
                <Select.Portal>
                  <Select.Positioner>
                    <Select.Popup>
                      <Select.ScrollUpArrow />
                      <Select.List>
                        <For each={SERVER_TYPES}>
                          {({ label, value }) => {
                            return (
                              <Select.Item value={value}>
                                <Select.ItemIndicator>
                                  <CheckIcon />
                                </Select.ItemIndicator>
                                <Select.ItemText>{label}</Select.ItemText>
                              </Select.Item>
                            );
                          }}
                        </For>
                      </Select.List>
                      <Select.ScrollDownArrow />
                    </Select.Popup>
                  </Select.Positioner>
                </Select.Portal>
              </Select.Root>
              <Field.Error match={!field.state.meta.isValid}>
                {field.state.meta.errors.join(',')}
              </Field.Error>
            </Field.Root>
          );
        }}
      />

      <form.Field
        name="numOfInstances"
        children={(field) => {
          return (
            <Field.Root
              name={field.name}
              invalid={!field.state.meta.isValid}
              dirty={field.state.meta.isDirty}
              touched={field.state.meta.isTouched}
            >
              <NumberField.Root
                value={field.state.value}
                onValueChange={field.handleChange}
                min={1}
                max={64}
              >
                <Field.Label>Number of instances</Field.Label>
                <NumberField.Group>
                  <NumberField.Decrement>
                    <MinusIcon />
                  </NumberField.Decrement>
                  <NumberField.Input onFocusOut={field.handleBlur} />
                  <NumberField.Increment>
                    <PlusIcon />
                  </NumberField.Increment>
                </NumberField.Group>
              </NumberField.Root>
              <Field.Error match={!field.state.meta.isValid}>
                {field.state.meta.errors.join(',')}
              </Field.Error>
            </Field.Root>
          );
        }}
      />

      <form.Field
        name="scalingThreshold"
        children={(field) => {
          return (
            <Field.Root
              name={field.name}
              invalid={!field.state.meta.isValid}
              dirty={field.state.meta.isDirty}
              touched={field.state.meta.isTouched}
            >
              <Fieldset.Root
                render={(renderProps) => (
                  <Slider.Root
                    value={field.state.value}
                    onValueChange={field.handleChange}
                    onValueCommitted={field.handleChange}
                    thumbAlignment="edge"
                    min={0}
                    max={1}
                    step={0.01}
                    format={{
                      style: 'percent',
                      minimumFractionDigits: 0,
                      maximumFractionDigits: 0,
                    }}
                    class="w-full gap-y-2"
                    {...(renderProps as any)}
                  />
                )}
              >
                <Fieldset.Legend>Scaling threshold</Fieldset.Legend>
                <Slider.Value class="col-start-2 text-end" />
                <Slider.Control>
                  <Slider.Track>
                    <Slider.Indicator />
                    <Slider.Thumb
                      index={0}
                      aria-label="Minimum threshold"
                      onFocusOut={field.handleBlur}
                    />
                    <Slider.Thumb
                      index={1}
                      aria-label="Maximum threshold"
                      onFocusOut={field.handleBlur}
                    />
                  </Slider.Track>
                </Slider.Control>
              </Fieldset.Root>
              <Field.Error match={!field.state.meta.isValid}>
                {field.state.meta.errors.join(',')}
              </Field.Error>
            </Field.Root>
          );
        }}
      />

      <form.Field
        name="storageType"
        children={(field) => {
          return (
            <Field.Root
              name={field.name}
              invalid={!field.state.meta.isValid}
              dirty={field.state.meta.isDirty}
              touched={field.state.meta.isTouched}
            >
              <Fieldset.Root
                render={(renderProps) => (
                  <RadioGroup
                    value={field.state.value}
                    onValueChange={field.handleChange}
                    class="gap-4"
                    {...(renderProps as any)}
                  />
                )}
              >
                <Fieldset.Legend class="-mt-px">Storage type</Fieldset.Legend>
                <For each={['ssd', 'hdd']}>
                  {(radioValue) => (
                    <Field.Item>
                      <Field.Label class="uppercase">
                        <Radio.Root value={radioValue}>
                          <Radio.Indicator />
                        </Radio.Root>
                        {radioValue}
                      </Field.Label>
                    </Field.Item>
                  )}
                </For>
              </Fieldset.Root>
              <Field.Error match={!field.state.meta.isValid}>
                {field.state.meta.errors.join(',')}
              </Field.Error>
            </Field.Root>
          );
        }}
      />

      <form.Field
        name="restartOnFailure"
        children={(field) => {
          return (
            <Field.Root
              name={field.name}
              invalid={!field.state.meta.isValid}
              dirty={field.state.meta.isDirty}
              touched={field.state.meta.isTouched}
            >
              <Field.Label class="gap-2">
                Restart on failure
                <Switch.Root
                  checked={field.state.value}
                  onCheckedChange={field.handleChange}
                  onFocusOut={field.handleBlur}
                >
                  <Switch.Thumb />
                </Switch.Root>
              </Field.Label>
              <Field.Error match={!field.state.meta.isValid}>
                {field.state.meta.errors.join(',')}
              </Field.Error>
            </Field.Root>
          );
        }}
      />

      <form.Field
        name="allowedNetworkProtocols"
        children={(field) => {
          return (
            <Field.Root
              name={field.name}
              invalid={!field.state.meta.isValid}
              dirty={field.state.meta.isDirty}
              touched={field.state.meta.isTouched}
            >
              <Fieldset.Root
                render={(renderProps) => (
                  <CheckboxGroup
                    value={field.state.value}
                    onValueChange={field.handleChange}
                    {...(renderProps as any)}
                  />
                )}
              >
                <Fieldset.Legend class="mb-2">Allowed network protocols</Fieldset.Legend>
                <div class="flex gap-4">
                  <For each={['http', 'https', 'ssh']}>
                    {(checkboxValue) => {
                      return (
                        <Field.Item>
                          <Field.Label class="uppercase">
                            <Checkbox.Root value={checkboxValue} onFocusOut={field.handleBlur}>
                              <Checkbox.Indicator>
                                <CheckIcon />
                              </Checkbox.Indicator>
                            </Checkbox.Root>
                            {checkboxValue}
                          </Field.Label>
                        </Field.Item>
                      );
                    }}
                  </For>
                </div>
              </Fieldset.Root>
              <Field.Error match={!field.state.meta.isValid}>
                {field.state.meta.errors.join(',')}
              </Field.Error>
            </Field.Root>
          );
        }}
      />

      <Button type="submit" class="mt-3">
        Launch server
      </Button>
    </form>
  );
}

export default function App() {
  return (
    <ToastProvider>
      <TanstackForm />
    </ToastProvider>
  );
}

function CaretUpDownIcon(props: JSX.IntrinsicElements['svg']) {
  return (
    <svg
      width="16"
      height="16"
      viewBox="0 0 16 16"
      fill="currentColor"
      {...props}
      style={props.style}
    >
      <path d="M11 10H5l3 3.5zm0-4H5l3-3.5z" />
    </svg>
  );
}

function CheckIcon(props: JSX.IntrinsicElements['svg']) {
  return (
    <svg
      width="16"
      height="16"
      viewBox="0 0 16 16"
      fill="none"
      stroke="currentColor"
      {...props}
      style={props.style}
    >
      <path d="m2.5 8.5 4 4 7-9" />
    </svg>
  );
}

function PlusIcon(props: JSX.IntrinsicElements['svg']) {
  return (
    <svg
      width="16"
      height="16"
      viewBox="0 0 16 16"
      fill="none"
      stroke="currentColor"
      stroke-linecap="square"
      stroke-linejoin="round"
      {...props}
      style={props.style}
    >
      <path d="M1.5 8h13M8 14.5v-13" />
    </svg>
  );
}

function MinusIcon(props: JSX.IntrinsicElements['svg']) {
  return (
    <svg
      width="16"
      height="16"
      viewBox="0 0 16 16"
      fill="none"
      stroke="currentColor"
      stroke-linecap="square"
      stroke-linejoin="round"
      {...props}
      style={props.style}
    >
      <path d="M1.5 8h13" />
    </svg>
  );
}

function isEmpty(object: Partial<Record<DeepKeys<FormValues>, ValidationError>>) {
  // eslint-disable-next-line
  for (const _ in object) {
    return false;
  }
  return true;
}

function cartesian<T extends string[][]>(...arrays: T): string[][] {
  return arrays.reduce<string[][]>(
    (acc, curr) => acc.flatMap((a) => curr.map((b) => [...a, b])),
    [[]],
  );
}

const REGIONS = cartesian(['us', 'eu', 'ap'], ['central', 'east', 'west'], ['1', '2', '3']).map(
  (part) => part.join('-'),
);

interface Image {
  url: string;
  name: string;
}
/* prettier-ignore */
const IMAGES: Image[] = ['nginx:1.29-alpine', 'node:22-slim', 'postgres:18', 'redis:8.2.2-alpine'].map((name) => ({
  url: `docker.io/library/${name}`,
  name,
}));

const SERVER_TYPES = [
  { label: 'Select server type', value: null },
  ...cartesian(['t', 'm'], ['1', '2'], ['small', 'medium', 'large']).map((part) => {
    const value = part.join('.').replace('.', '');
    return { label: value, value };
  }),
];

Initialize the form

Create a form instance with initial values and a submission callback. This example retains the upstream field API through the local Solid controller.

Initialize a form instance
import { createControlledForm } from './solid-controller';

const form = createControlledForm({
  defaultValues: { username: '', email: '' },
  onSubmit: async ({ value }) => {
    await fetch('/api/account', { method: 'POST', body: JSON.stringify(value) });
  },
});

Integrate components

Use <form.Field> to connect each control. Read its reactive state in JSX and forward the change and focus-out handlers.

Integrating form fields with Base UI components
import { Field } from 'base-ui-solid/field';

<form.Field name="username">
  {(field) => (
    <Field.Root
      name={field.name}
      invalid={!field.state.meta.isValid}
      dirty={field.state.meta.isDirty}
      touched={field.state.meta.isTouched}
    >
      <Field.Label>Username</Field.Label>
      <Field.Control
        value={field.state.value}
        onValueChange={field.handleChange}
        onFocusOut={field.handleBlur}
      />
      <Field.Error match={!field.state.meta.isValid}>
        {field.state.meta.errors.join(', ')}
      </Field.Error>
    </Field.Root>
  )}
</form.Field>;

Form validation

Return field errors from the form-level validator. After the first submit attempt, changes revalidate the form.

Form-level validators
const form = createControlledForm({
  defaultValues: { username: '', email: '' },
  validators: {
    onDynamic: ({ value }) => ({
      fields: {
        ...(!value.username ? { username: 'This field is required.' } : {}),
        ...(!value.email.includes('@') ? { email: 'Enter a valid email.' } : {}),
      },
    }),
  },
});

Field validation

The local controller supports synchronous validation. For asynchronous checks, use Base UI’s validate prop and validationDebounceTime as described in Custom validation.

Text input using custom asynchronous validation
<Field.Root
  name="username"
  validationMode="onChange"
  validationDebounceTime={300}
  validate={async (value) => {
    const response = await fetch(`/api/username?value=${encodeURIComponent(String(value))}`);
    const result = await response.json();
    return result.available ? null : 'This username is not available.';
  }}
>
  <Field.Control />
  <Field.Error />
</Field.Root>

Submitting data

Prevent the native navigation and call the form’s submission method. The controller validates before invoking onSubmit.

Form submission handler
<form
  onSubmit={(event) => {
    event.preventDefault();
    form.handleSubmitForm();
  }}
>
  <button type="submit">Submit</button>
</form>