Skip to content
Esc
navigateopen⌘Jpreview
On this page

Form

Validated form built on Base UI Form and Field.

This is your public display name.

'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>
  );
}
  • invalid puts 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.
  • register spreads name, ref, onChange, onBlur onto our Input, 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.

Was this page helpful?