Form
Validated form built on Base UI Form and Field.
'use client';
import * as stylex from '@stylexjs/stylex';
import { Button } from '@/components/ui/button';
import {
Field,
FieldDescription,
FieldError,
FieldLabel,
} from '@/components/ui/field';
import { Form } from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { container } from '@/lib/constants.stylex';
export default function FormDemo() {
return (
<Form
style={styles.form}
onFormSubmit={(values) => {
console.log(values);
}}
>
<Field
name="username"
validate={(value) =>
typeof value === 'string' && value.length < 2
? 'Username must be at least 2 characters.'
: null
}
>
<FieldLabel>Username</FieldLabel>
<Input placeholder="madeui" required />
<FieldDescription>This is your public display name.</FieldDescription>
<FieldError />
</Field>
<Field name="email">
<FieldLabel>Email</FieldLabel>
<Input type="email" placeholder="m@example.com" required />
<FieldError />
</Field>
<Button type="submit" style={styles.submit}>
Submit
</Button>
</Form>
);
}
const styles = stylex.create({
form: {
maxWidth: container.md,
},
submit: {
alignSelf: 'flex-start',
},
});
Install
npx shadcn@latest add @madeui/form
Usage
import { Form } from '@/components/ui/form';
import {
Field,
FieldDescription,
FieldError,
FieldLabel,
} from '@/components/ui/field';
Composition
<Form onFormSubmit={(values) => {}}>
<Field name="email">
<FieldLabel />
<Input />
<FieldDescription />
<FieldError />
</Field>
<Button type="submit" />
</Form>
Form validates the Fields inside it. A field’s control is whatever Base
UI control you place in it — Input, Textarea, Checkbox, Select,
Switch, RadioGroup, Slider, NumberField all join the field
automatically (label association, aria-describedby, aria-invalid).
Native constraint attributes (required, type="email", minLength, …)
and the field’s validate function both feed FieldError.
Server errors
Pass externally produced errors — e.g. from a server action — via errors,
keyed by field name:
const [errors, setErrors] = React.useState({});
<Form
errors={errors}
onFormSubmit={async (values) => {
const response = await submit(values);
if (response.errors) setErrors(response.errors); // { email: 'Already in use' }
}}
>
React Hook Form
Base UI fields interoperate with react-hook-form:
keep RHF as the source of truth, tell the field about it with invalid, and
render RHF’s message through FieldError (external errors render as-is).
Use a plain <form> — RHF owns submission.
'use client';
import { useForm } from 'react-hook-form';
import { Button } from '@/components/ui/button';
import { Field, FieldError, FieldLabel } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
export function ProfileForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<{ email: string }>();
return (
<form onSubmit={handleSubmit((values) => console.log(values))}>
<Field invalid={!!errors.email}>
<FieldLabel>Email</FieldLabel>
<Input
type="email"
{...register('email', { required: 'Email is required.' })}
/>
<FieldError errors={[errors.email]} />
</Field>
<Button type="submit">Submit</Button>
</form>
);
}
invalidputs the field into its invalid state (aria-invalid, red label) under RHF’s control.FieldError errors={[...]}renders RHF’s messages directly — de-duplicated, nothing when empty.registerspreadsname,ref,onChange,onBluronto ourInput, which forwards them to the native element.
For controlled Base UI controls (Select, Checkbox, Slider, …) use RHF’s
Controller and map field.value / field.onChange to the control’s
value / onValueChange (or checked / onCheckedChange).
TanStack Form
The same pattern works with TanStack Form — its
form.Field render prop supplies state and handlers, our Field renders it
accessibly:
'use client';
import { useForm } from '@tanstack/react-form';
import { Button } from '@/components/ui/button';
import { Field, FieldError, FieldLabel } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
export function ProfileForm() {
const form = useForm({
defaultValues: { email: '' },
onSubmit: ({ value }) => console.log(value),
});
return (
<form
onSubmit={(event) => {
event.preventDefault();
form.handleSubmit();
}}
>
<form.Field
name="email"
validators={{
onChange: ({ value }) =>
value.includes('@') ? undefined : 'Enter a valid email.',
}}
>
{(field) => (
<Field invalid={field.state.meta.errors.length > 0}>
<FieldLabel>Email</FieldLabel>
<Input
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(event) => field.handleChange(event.target.value)}
/>
<FieldError
errors={field.state.meta.errors.map((message) => ({
message: String(message),
}))}
/>
</Field>
)}
</form.Field>
<Button type="submit">Submit</Button>
</form>
);
}
API reference
Built on Base UI Form. All props are forwarded (errors, onFormSubmit, validationMode, actionsRef, …); see the Base UI Form API reference. See Field for the field parts.
| Prop | Type | Default | Description |
|---|---|---|---|
validationMode |
'onSubmit' | 'onBlur' | 'onChange' |
'onSubmit' |
When fields validate; a field’s own validationMode wins. |
errors |
Record<string, string | string[]> |
— | External errors keyed by field name (e.g. from a server). |
onFormSubmit |
(values) => void |
— | Called with the form values when validation passes. |
style |
StyleXStyles |
— | StyleX styles merged last — always win over the component’s own styles. |