Forms
A guide to building forms with Base UI components.
View as MarkdownBase 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. They also integrate seamlessly with third-party libraries like React Hook Form and TanStack Form.
'use client';
import * as React from 'react';
import { ChevronDown, ChevronsUpDown, Check, Plus, Minus } from 'lucide-react';
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 className="relative flex flex-col gap-1 text-sm leading-5 text-gray-900">
<Field.Label>Region</Field.Label>
<Combobox.Input placeholder="e.g. eu-central-1" />
<div className="absolute right-2 bottom-0 flex h-10 items-center justify-center text-gray-600">
<Combobox.Clear />
<Combobox.Trigger>
<ChevronDown className="size-4" />
</Combobox.Trigger>
</div>
</div>
<Combobox.Portal>
<Combobox.Positioner>
<Combobox.Popup>
<Combobox.Empty>No matches</Combobox.Empty>
<Combobox.List>
{(region: string) => {
return (
<Combobox.Item key={region} value={region}>
<Combobox.ItemIndicator>
<Check className="size-4" />
</Combobox.ItemIndicator>
<div className="col-start-2">{region}</div>
</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 key={image.url} value={image}>
<span className="text-base leading-6">{image.name}</span>
<span className="font-mono whitespace-nowrap text-xs leading-4 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 className="flex flex-col items-start gap-1">
<Select.Label>Server type</Select.Label>
<Select.Trigger className="w-48">
<Select.Value />
<Select.Icon>
<ChevronsUpDown className="size-4" />
</Select.Icon>
</Select.Trigger>
</div>
<Select.Portal>
<Select.Positioner>
<Select.Popup>
<Select.ScrollUpArrow />
<Select.List>
{SERVER_TYPES.map(({ label, value }) => {
return (
<Select.Item key={value} value={value}>
<Select.ItemIndicator>
<Check className="size-4" />
</Select.ItemIndicator>
<Select.ItemText>{label}</Select.ItemText>
</Select.Item>
);
})}
</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>
<Minus className="size-4" />
</NumberField.Decrement>
<NumberField.Input className="!w-16" />
<NumberField.Increment>
<Plus className="size-4" />
</NumberField.Increment>
</NumberField.Group>
</NumberField.Root>
<Field.Error />
</Field.Root>
<Field.Root name="scalingThreshold">
<Fieldset.Root
render={
<Slider.Root
defaultValue={[0.2, 0.8]}
thumbAlignment="edge"
min={0}
max={1}
step={0.01}
format={{
style: 'percent',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}}
className="w-98/100 gap-y-2"
/>
}
>
<Fieldset.Legend>Scaling threshold</Fieldset.Legend>
<Slider.Value className="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={<RadioGroup<'ssd' | 'hdd'> className="gap-4" defaultValue="ssd" />}>
<Fieldset.Legend className="-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 className="gap-4">
Restart on failure
<Switch.Root defaultChecked>
<Switch.Thumb />
</Switch.Root>
</Field.Label>
</Field.Root>
<Field.Root name="allowedNetworkProtocols">
<Fieldset.Root render={<CheckboxGroup defaultValue={[]} />}>
<Fieldset.Legend className="mb-2">Allowed network protocols</Fieldset.Legend>
<div className="flex gap-4">
{['http', 'https', 'ssh'].map((val) => {
return (
<Field.Item key={val}>
<Field.Label className="uppercase">
<Checkbox.Root value={val}>
<Checkbox.Indicator>
<Check className="size-3" />
</Checkbox.Indicator>
</Checkbox.Root>
{val}
</Field.Label>
</Field.Item> );
})}
</div>
</Fieldset.Root>
</Field.Root>
<Button type="submit" className="mt-3">
Launch server
</Button>
</Form>
);
}
export default function App() {
return (
<ToastProvider>
<ExampleForm />
</ToastProvider>
);
}
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;
}
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:
InputNumberFieldAutocompleteCombobox(input outside popup)CheckboxRadioSwitch
You can implicitly label <Checkbox>, <Radio> and <Switch> components by enclosing them with <Field.Label>:
import { Field } from '@base-ui/react/field';
import { Switch } from '@base-ui/react/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 anaria-labelon 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:
import { Form } from '@base-ui/react/form';
import { Field } from '@base-ui/react/field';
import { Select } from '@base-ui/react/select';
import { Slider } from '@base-ui/react/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>:
import { Form } from '@base-ui/react/form';
import { Field } from '@base-ui/react/field';
import { Fieldset } from '@base-ui/react/fieldset';
import { Radio } from '@base-ui/react/radio';
import { RadioGroup } from '@base-ui/react/radio-group';
import { Slider } from '@base-ui/react/slider';
<Form>
<Field.Root>
<Fieldset.Root render={<Slider.Root />}>
<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={<RadioGroup />}>
<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:
import { Form } from '@base-ui/react/form';
import { Field } from '@base-ui/react/field';
import { Fieldset } from '@base-ui/react/fieldset';
import { Checkbox } from '@base-ui/react/checkbox';
import { CheckboxGroup } from '@base-ui/react/checkbox-group';
<Field.Root>
<Fieldset.Root render={<CheckboxGroup />}>
<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:
import { Form } from '@base-ui/react/form';
import { Field } from '@base-ui/react/field';
import { Combobox } from '@base-ui/react/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:
import { Form } from '@base-ui/react/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:
import { Form } from '@base-ui/react/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:
requiredspecifies a required field.minLengthandmaxLengthspecify a valid length for text fields.patternspecifies a regular expression that the field value must match.stepspecifies an increment that numeric field values must be an integral multiple of.
import { Field } from '@base-ui/react/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.
import { Field } from '@base-ui/react/field';
import { Select } from '@base-ui/react/select';
<Field.Root name="apple">
<Select.Root>
<Select.Label>Apple</Select.Label>
<div className="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.onBlurvalidates the field when focus moves away.onChangevalidates 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.
import { Field } from '@base-ui/react/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(
,
/* 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.
import { Form } from '@base-ui/react/form';
import { Field } from '@base-ui/react/field';
async function submitToServer(/* payload */) {
return {
errors: { {
promoCode: 'This promo code has expired',
},
};
}
const [errors, setErrors] = React.useState();
<Form
errors={errors} {
onSubmit={async (event) => {
event.preventDefault();
const response = await submitToServer(/* data */);
setErrors(response.errors);
}}
>
<Field.Root name="promoCode" />
</Form>When using Server Functions with Form Actions you can return server-side errors from useActionState to the errors prop. A demo is available here.
// app/form.tsx
'use client';
import { Form } from '@base-ui/react/form';
import { Field } from '@base-ui/react/field';
import { login } from './actions';
const [state, formAction, loading] = React.useActionState(login, {});
<Form action={formAction} errors={state.errors}>
<Field.Root name="password">
<Field.Control />
<Field.Error />
</Field.Root>
</Form>;
// app/actions.ts
'use server';
export async function login(formData: FormData) {
const result = authenticateUser(formData);
if (!result.success) {
return {
errors: {
password: 'Invalid username or password',
},
};
}
/* redirect on the server on success */
}
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:
<Field.Error match="valueMissing">You must create a username</Field.Error>
React Hook Form
React Hook Form is a popular library that you can integrate with Base UI to externally manage form and field state for your existing components.
'use client';
import * as React from 'react';
import { useForm, Controller } from 'react-hook-form';
import { ChevronDown, ChevronsUpDown, Check, Plus, Minus } from 'lucide-react';
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 ReactHookForm() {
const toastManager = useToastManager();
const { control, handleSubmit } = useForm<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={({
field: { ref, name, value, onBlur, onChange },
fieldState: { invalid, isTouched, isDirty, error },
}) => (
<Field.Root name={name} invalid={invalid} touched={isTouched} dirty={isDirty}>
<Field.Label>Server name</Field.Label>
<Field.Control
ref={ref}
value={value}
onBlur={onBlur}
onValueChange={onChange}
placeholder="e.g. api-server-01"
/>
<Field.Description>Must be 3 or more characters long</Field.Description>
<Field.Error match={!!error}>{error?.message}</Field.Error>
</Field.Root>
)}
/>
<Controller
name="region"
control={control}
rules={{
required: 'This field is required.',
}}
render={({
field: { ref, name, value, onBlur, onChange },
fieldState: { invalid, isTouched, isDirty, error },
}) => (
<Field.Root name={name} invalid={invalid} touched={isTouched} dirty={isDirty}>
<Combobox.Root items={REGIONS} value={value} onValueChange={onChange}>
<div className="relative flex flex-col gap-1 text-sm leading-5 text-gray-900">
<Field.Label>Region</Field.Label>
<Combobox.Input placeholder="e.g. eu-central-1" ref={ref} onBlur={onBlur} />
<div className="absolute right-2 bottom-0 flex h-10 items-center justify-center text-gray-600">
<Combobox.Clear />
<Combobox.Trigger>
<ChevronDown className="size-4" />
</Combobox.Trigger>
</div>
</div>
<Combobox.Portal>
<Combobox.Positioner>
<Combobox.Popup>
<Combobox.Empty>No matches</Combobox.Empty>
<Combobox.List>
{(region: string) => {
return (
<Combobox.Item key={region} value={region}>
<Combobox.ItemIndicator>
<Check className="size-4" />
</Combobox.ItemIndicator>
<div className="col-start-2">{region}</div>
</Combobox.Item> );
}}
</Combobox.List>
</Combobox.Popup>
</Combobox.Positioner>
</Combobox.Portal>
</Combobox.Root>
<Field.Error match={!!error}>{error?.message}</Field.Error>
</Field.Root>
)}
/>
<Controller
name="containerImage"
control={control}
rules={{
required: 'This field is required.',
}}
render={({
field: { ref, name, value, onBlur, onChange },
fieldState: { invalid, isTouched, isDirty, error },
}) => (
<Field.Root name={name} invalid={invalid} touched={isTouched} dirty={isDirty}>
<Autocomplete.Root
items={IMAGES}
mode="both"
itemToStringValue={(itemValue: Image) => itemValue.url}
value={value}
onValueChange={onChange}
>
<Field.Label>Container image</Field.Label>
<Autocomplete.Input
placeholder="e.g. docker.io/library/node:latest"
ref={ref}
onBlur={onBlur}
/>
<Field.Description>Enter a registry URL with optional tags</Field.Description>
<Autocomplete.Portal>
<Autocomplete.Positioner>
<Autocomplete.Popup>
<Autocomplete.List>
{(image: Image) => {
return (
<Autocomplete.Item key={image.url} value={image}>
<span className="text-base leading-6">{image.name}</span>
<span className="font-mono whitespace-nowrap text-xs leading-4 opacity-80">
{image.url}
</span>
</Autocomplete.Item>
);
}}
</Autocomplete.List>
</Autocomplete.Popup>
</Autocomplete.Positioner>
</Autocomplete.Portal>
</Autocomplete.Root>
<Field.Error match={!!error}>{error?.message}</Field.Error>
</Field.Root>
)}
/>
<Controller
name="serverType"
control={control}
rules={{
required: 'This field is required.',
}}
render={({
field: { ref, name, value, onBlur, onChange },
fieldState: { invalid, isTouched, isDirty, error },
}) => (
<Field.Root name={name} invalid={invalid} touched={isTouched} dirty={isDirty}>
<Select.Root items={SERVER_TYPES} value={value} onValueChange={onChange} inputRef={ref}>
<div className="flex flex-col items-start gap-1">
<Select.Label>Server type</Select.Label>
<Select.Trigger className="w-48" onBlur={onBlur}>
<Select.Value />
<Select.Icon>
<ChevronsUpDown className="size-4" />
</Select.Icon>
</Select.Trigger>
</div>
<Select.Portal>
<Select.Positioner>
<Select.Popup>
<Select.ScrollUpArrow />
<Select.List>
{SERVER_TYPES.map(({ label, value: serverType }) => {
return (
<Select.Item key={serverType} value={serverType}>
<Select.ItemIndicator>
<Check className="size-4" />
</Select.ItemIndicator>
<Select.ItemText>{label}</Select.ItemText>
</Select.Item>
);
})}
</Select.List>
<Select.ScrollDownArrow />
</Select.Popup>
</Select.Positioner>
</Select.Portal>
</Select.Root>
<Field.Error match={!!error}>{error?.message}</Field.Error>
</Field.Root>
)}
/>
<Controller
name="numOfInstances"
control={control}
rules={{
required: 'This field is required.',
}}
render={({
field: { ref, name, value, onBlur, onChange },
fieldState: { invalid, isTouched, isDirty, error },
}) => (
<Field.Root name={name} invalid={invalid} touched={isTouched} dirty={isDirty}>
<NumberField.Root value={value} min={1} max={64} onValueChange={onChange}> <Field.Label>Number of instances</Field.Label>
<NumberField.Group>
<NumberField.Decrement>
<Minus className="size-4" />
</NumberField.Decrement>
<NumberField.Input className="!w-16" ref={ref} onBlur={onBlur} />
<NumberField.Increment>
<Plus className="size-4" />
</NumberField.Increment>
</NumberField.Group>
</NumberField.Root>
<Field.Error match={!!error}>{error?.message}</Field.Error>
</Field.Root>
)}
/>
<Controller
name="scalingThreshold"
control={control}
render={({
field: { ref, name, value, onBlur, onChange },
fieldState: { invalid, isTouched, isDirty },
}) => (
<Field.Root name={name} invalid={invalid} touched={isTouched} dirty={isDirty}>
<Fieldset.Root
render={
<Slider.Root
value={value}
onValueChange={onChange}
onValueCommitted={onChange}
thumbAlignment="edge"
min={0}
max={1}
step={0.01}
format={{
style: 'percent',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}}
className="w-98/100 gap-y-2"
/>
}
>
<Fieldset.Legend>Scaling threshold</Fieldset.Legend>
<Slider.Value className="col-start-2 text-end" />
<Slider.Control>
<Slider.Track>
<Slider.Indicator />
<Slider.Thumb
index={0}
aria-label="Minimum threshold"
onBlur={onBlur}
inputRef={ref}
/>
<Slider.Thumb index={1} aria-label="Maximum threshold" onBlur={onBlur} />
</Slider.Track>
</Slider.Control>
</Fieldset.Root>
</Field.Root>
)}
/>
<Controller
name="storageType"
control={control}
render={({
field: { ref, name, value, onBlur, onChange },
fieldState: { invalid, isTouched, isDirty },
}) => (
<Field.Root name={name} invalid={invalid} touched={isTouched} dirty={isDirty}>
<Fieldset.Root
render={
<RadioGroup
className="gap-4"
value={value}
onValueChange={onChange}
inputRef={ref}
/>
}
>
<Fieldset.Legend className="-mt-px">Storage type</Fieldset.Legend>
<Field.Item>
<Field.Label>
<Radio.Root value="ssd" onBlur={onBlur}>
<Radio.Indicator />
</Radio.Root>
SSD
</Field.Label>
</Field.Item>
<Field.Item>
<Field.Label>
<Radio.Root value="hdd" onBlur={onBlur}>
<Radio.Indicator />
</Radio.Root>
HDD
</Field.Label>
</Field.Item>
</Fieldset.Root>
</Field.Root>
)}
/>
<Controller
name="restartOnFailure"
control={control}
render={({
field: { ref, name, value, onBlur, onChange },
fieldState: { invalid, isTouched, isDirty },
}) => (
<Field.Root name={name} invalid={invalid} touched={isTouched} dirty={isDirty}>
<Field.Label className="gap-4">
Restart on failure
<Switch.Root
checked={value}
inputRef={ref}
onCheckedChange={onChange}
onBlur={onBlur}
>
<Switch.Thumb />
</Switch.Root> </Field.Label>
</Field.Root>
)}
/>
<Controller
name="allowedNetworkProtocols"
control={control}
render={({
field: { ref, name, value, onBlur, onChange },
fieldState: { invalid, isTouched, isDirty },
}) => (
<Field.Root name={name} invalid={invalid} touched={isTouched} dirty={isDirty}>
<Fieldset.Root render={<CheckboxGroup value={value} onValueChange={onChange} />}>
<Fieldset.Legend className="mb-2">Allowed network protocols</Fieldset.Legend>
<div className="flex gap-4">
{['http', 'https', 'ssh'].map((val) => {
return (
<Field.Item key={val}>
<Field.Label className="uppercase">
<Checkbox.Root
value={val}
inputRef={val === 'http' ? ref : undefined}
onBlur={onBlur}
>
<Checkbox.Indicator>
<Check className="size-3" />
</Checkbox.Indicator>
</Checkbox.Root>
{val}
</Field.Label>
</Field.Item>
);
})}
</div>
</Fieldset.Root>
</Field.Root>
)}
/>
<Button type="submit" className="mt-3">
Launch server
</Button>
</Form>
);
}
export default function App() {
return (
<ToastProvider>
<ReactHookForm />
</ToastProvider>
);
}
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;
}
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
Initialize the form with the useForm hook, assigning the initial value of each field by their name in the defaultValues parameter:
import { useForm } from 'react-hook-form';
const { control, handleSubmit } = useForm<FormValues>({
defaultValues: {
username: '',
email: '',
},
});
Integrate components
Use the <Controller> component to integrate with any <Field> component, forwarding the name, field, and fieldState render props to the appropriate part:
import { useForm, Controller } from "react-hook-form";
import { Field } from '@base-ui/react/field';
const { control, handleSubmit} = useForm({
defaultValues: {
username: '',
}
})
<Controller
name="username"
control={control}
render={({
field: { name, ref, value, onBlur, onChange },
fieldState: { invalid, isTouched, isDirty, error },
}) => (
<Field.Root name={name} invalid={invalid} touched={isTouched} dirty={isDirty}>
<Field.Label>Username</Field.Label>
<Field.Description>
May appear where you contribute or are mentioned. You can remove it at any time.
</Field.Description>
<Field.Control
placeholder="e.g. alice132"
value={value}
onBlur={onBlur}
onValueChange={onChange}
ref={ref}
/>
<Field.Error match={!!error}>
{error?.message}
</Field.Error>
</Field.Root>
)}
/>
For React Hook Form to focus invalid fields when performing validation, you must ensure that any wrapping components forward the ref to the underlying Base UI component. You can typically accomplish this using the inputRef prop, or directly as the ref for components that render an input element like <NumberField.Input>.
Field validation
Specify rules on the <Controller> in the same format as register options, and use the match prop to delegate control of the error rendering:
import { Controller } from "react-hook-form";
import { Field } from '@base-ui/react/field';
<Controller
name="username"
control={control}
rules={{
required: 'This is a required field',
minLength: { value: 2, message: 'Too short' },
validate: (value) => {
if (/* custom logic */) {
return 'Invalid'
}
return null;
},
}}
render={({
field: { name, ref, value, onBlur, onChange },
fieldState: { invalid, isTouched, isDirty, error },
}) => (
<Field.Root name={name} invalid={invalid} touched={isTouched} dirty={isDirty}>
<Field.Label>Username</Field.Label>
<Field.Description>
May appear where you contribute or are mentioned. You can remove it at any time.
</Field.Description>
<Field.Control
placeholder="e.g. alice132"
value={value}
onBlur={onBlur}
onValueChange={onChange}
ref={ref}
/>
<Field.Error match={!!error}>
{error?.message}
</Field.Error>
</Field.Root>
)}
/>
Submitting data
Wrap your submit handler function with handleSubmit to receive the form values as a JavaScript object for further handling:
import { useForm } from 'react-hook-form';
import { Form } from '@base-ui/react/form';
interface FormValues {
username: string;
email: string;
}
const { handleSubmit } = useForm<FormValues>();
async function submitForm(data: FormValues) {
// transform the object and/or submit it to a server
await fetch(/* ... */);
}
<Form onSubmit={handleSubmit(submitForm)} />TanStack Form
TanStack Form is a form library with a function-based API for orchestrating validations that can also be integrated with Base UI.
'use client';
import * as React from 'react';
import { useForm, revalidateLogic, DeepKeys, ValidationError } from '@tanstack/react-form';
import { ChevronDown, ChevronsUpDown, Check, Plus, Minus } from 'lucide-react';
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';
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 = useForm({
defaultValues,
onSubmit: ({ value: formValues }) => {
toastManager.add({
title: 'Form submitted',
description: 'The form contains these values:',
data: formValues,
});
},
validationLogic: revalidateLogic({
mode: 'submit',
modeAfterSubmission: 'change',
}),
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"
className="flex w-full max-w-3xs sm:max-w-[20rem] flex-col gap-5"
noValidate
onSubmit={(event) => {
event.preventDefault();
form.handleSubmit();
}}
>
<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}
onBlur={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 className="relative flex flex-col gap-1 text-sm leading-5 text-gray-900">
<Field.Label>Region</Field.Label>
<Combobox.Input placeholder="e.g. eu-central-1" onBlur={field.handleBlur} />
<div className="absolute right-2 bottom-0 flex h-10 items-center justify-center text-gray-600">
<Combobox.Clear />
<Combobox.Trigger>
<ChevronDown className="size-4" />
</Combobox.Trigger>
</div>
</div>
<Combobox.Portal>
<Combobox.Positioner>
<Combobox.Popup>
<Combobox.Empty>No matches</Combobox.Empty>
<Combobox.List>
{(region: string) => {
return (
<Combobox.Item key={region} value={region}>
<Combobox.ItemIndicator>
<Check className="size-3" />
</Combobox.ItemIndicator>
<div className="col-start-2">{region}</div>
</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"
onBlur={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 key={image.url} value={image}>
<span className="text-base leading-6">{image.name}</span>
<span className="font-mono whitespace-nowrap text-xs leading-4 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 className="flex flex-col items-start gap-1">
<Select.Label>Server type</Select.Label>
<Select.Trigger className="!w-48" onBlur={field.handleBlur}>
<Select.Value />
<Select.Icon> <ChevronsUpDown className="size-4" />
</Select.Icon>
</Select.Trigger>
</div>
<Select.Portal>
<Select.Positioner>
<Select.Popup>
<Select.ScrollUpArrow />
<Select.List>
{SERVER_TYPES.map(({ label, value }) => {
return (
<Select.Item key={value} value={value}>
<Select.ItemIndicator>
<Check className="size-3" />
</Select.ItemIndicator>
<Select.ItemText>{label}</Select.ItemText>
</Select.Item>
);
})}
</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>
<Minus className="size-4" />
</NumberField.Decrement>
<NumberField.Input className="!w-16" onBlur={field.handleBlur} />
<NumberField.Increment>
<Plus className="size-4" />
</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={
<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,
}}
className="w-98/100 gap-y-2"
/>
}
>
<Fieldset.Legend>Scaling threshold</Fieldset.Legend>
<Slider.Value className="col-start-2 text-end" />
<Slider.Control>
<Slider.Track>
<Slider.Indicator />
<Slider.Thumb
index={0}
aria-label="Minimum threshold"
onBlur={field.handleBlur}
/>
<Slider.Thumb
index={1}
aria-label="Maximum threshold"
onBlur={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={
<RadioGroup
value={field.state.value}
onValueChange={field.handleChange}
className="gap-4"
/>
}
>
<Fieldset.Legend className="-mt-px">Storage type</Fieldset.Legend>
{['ssd', 'hdd'].map((radioValue) => (
<Field.Item key={radioValue}>
<Field.Label className="uppercase">
<Radio.Root value={radioValue}>
<Radio.Indicator />
</Radio.Root>
{radioValue}
</Field.Label>
</Field.Item>
))}
</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 className="gap-4">
Restart on failure
<Switch.Root
checked={field.state.value}
onCheckedChange={field.handleChange}
onBlur={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={
<CheckboxGroup value={field.state.value} onValueChange={field.handleChange} />
}
>
<Fieldset.Legend className="mb-2">Allowed network protocols</Fieldset.Legend>
<div className="flex gap-4">
{['http', 'https', 'ssh'].map((checkboxValue) => {
return (
<Field.Item key={checkboxValue}>
<Field.Label className="uppercase">
<Checkbox.Root value={checkboxValue} onBlur={field.handleBlur}>
<Checkbox.Indicator>
<Check className="size-3" />
</Checkbox.Indicator>
</Checkbox.Root>
{checkboxValue}
</Field.Label>
</Field.Item>
);
})}
</div>
</Fieldset.Root>
<Field.Error match={!field.state.meta.isValid}>
{field.state.meta.errors.join(',')}
</Field.Error>
</Field.Root>
);
}}
/>
<Button type="submit" className="mt-3">
Launch server
</Button>
</form>
);
}
export default function App() {
return ( <ToastProvider>
<TanstackForm />
</ToastProvider>
);
}
function isEmpty(object: Partial<Record<DeepKeys<FormValues>, ValidationError>>) {
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;
}
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 the useForm hook, assigning the initial value of each field by their name in the defaultValues parameter:
import { useForm } from '@tanstack/react-form';
interface FormValues {
username: string;
email: string;
}
const defaultValues: FormValues = {
username: '',
email: '',
};
/* useForm returns a form instance */
const form = useForm<FormValues>({
defaultValues,
});
Integrate components
Use the <form.Field> component from the form instance to integrate with Base UI components using the children prop, forwarding the various field render props to the appropriate part:
import { useForm } from '@tanstack/react-form';
import { Field } from '@base-ui/react/field';
const form = useForm(/* defaultValues, other parameters */)
<form>
<form.Field
name="username"
children={(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}
onBlur={field.handleBlur}
placeholder="e.g. bob276"
/>
<Field.Error match={!field.state.meta.isValid}>
{field.state.meta.errors.join(',')}
</Field.Error>
</Field.Root>
)}
/>
</form>
The Base UI <Form> component is not needed when using TanStack Form.
Form validation
To configure a native <form>-like validation strategy:
- Use the additional
revalidateLogichook and pass it touseForm. - Pass a validation function to the
validators.onDynamicprop on<form.Field>that returns an error object with keys corresponding to the fieldnames.
This validates all fields when the first submission is attempted, and revalidates any invalid fields when their values change again.
import { useForm, revalidateLogic } from '@tanstack/react-form';
const form = useForm({
defaultValues: {
username: '',
email: '',
},
validationLogic: revalidateLogic({
mode: 'submit',
modeAfterSubmission: 'change',
}),
validators: {
onDynamic: ({ value: formValues }) => {
const errors = {};
if (!formValues.username) {
errors.username = 'Username is required.';
} else if (formValues.username.length < 3) {
errors.username = 'At least 3 characters.';
}
if (!formValues.email) {
errors.email = 'Email is required.';
} else if (!isValidEmail(formValues.email)) {
errors.email = 'Invalid email address.';
}
return { form: errors, fields: errors };
},
},
});
Field validation
You can pass additional validator functions to individual <form.Field> components to add validations on top of the form-level validators:
import { Field } from '@base-ui/react/field';
import { useForm } from '@tanstack/react-form';
const form = useForm();
<form.Field
name="username"
validators={{
onChangeAsync: async ({ value: username }) => {
const result = await fetch(
/* check the availability of a username from an external API */
);
return result.success ? undefined : `${username} is not available.`
}
}}
children={(field) => (
<Field.Root name={field.name} /* forward the field props */ />
)}
>
Submitting data
To submit the form:
- Pass a submit handler function to the
onSubmitparameter ofuseForm. - Call
form.handleSubmit()from an event handler such as formonSubmitoronClickon a button.
import { useForm } from '@tanstack/react-form';
const form = useForm({
onSubmit: async ({ value: formValues }) => {
await fetch(/* POST the `formValues` to an external API */);
},
});
<form
onSubmit={(event) => {
event.preventDefault();
form.handleSubmit();
}}
>
{/* form fields */}
<button type="submit">Submit</button>
</form>