Form
A native form element with consolidated error handling across fields.
API Reference
Original primitive API
Behavior, accessibility details, and low-level props are documented by Base UI.
Basic
import { type FormErrors, Form, Field, FieldLabel, Input, FieldError, Button,} from "moduix";import { useState } from "react";async function validateHomepage(homepage: string) { await new Promise((resolve) => { setTimeout(resolve, 500); }); try { const value = new URL(homepage); if (value.hostname.endsWith("example.com")) { return { homepage: "The example.com domain is not allowed.", }; } return {}; } catch { return { homepage: "Please enter a valid URL.", }; }}export function FormDemo() { const [errors, setErrors] = useState({} as FormErrors); const [submitting, setSubmitting] = useState(false); return ( <Form errors={errors} className={styles.form} onSubmit={async (event) => { event.preventDefault(); const formData = new FormData(event.currentTarget); const homepage = String(formData.get("homepage") ?? ""); setSubmitting(true); const nextErrors = await validateHomepage(homepage); setErrors(nextErrors); setSubmitting(false); }} > <Field name="homepage" validationMode="onBlur"> <FieldLabel>Homepage</FieldLabel> <Input type="url" required defaultValue="https://example.com" placeholder="https://example.com" pattern="https?://.*" /> <FieldError match="valueMissing">Please enter a homepage URL.</FieldError> <FieldError match="patternMismatch"> Please start with http:// or https://. </FieldError> <FieldError /> </Field> <Button type="submit" loading={submitting}> Submit </Button> </Form> );}.form { width: min(20rem, 100%);}Full list of component variables available for project-level overrides.
| Property | Default | Description |
|---|---|---|
| --form-gap | var(--spacing-4) | Controls spacing between form children. |
| --form-max-width | none | Controls the root form max width. |
| --form-width | 100% | Controls the root form width. |
Interactive variables scoped for docs preview without changing size scale tokens.
| Property | Value | Default | Description |
|---|---|---|---|
| --form-gap | var(--spacing-4) | Controls spacing between form children. | |
| --form-max-width | none | Controls the root form max width. | |
| --form-width | 100% | Controls the root form width. |
Anatomy
Form is composed as a root wrapper around native form semantics and validation orchestration.
Compose it with Field parts and submit controls inside the same root.
Form
├─ Field
│ ├─ FieldLabel
│ ├─ control (Input, FieldControl, etc.)
│ └─ FieldError / FieldDescription
└─ submit actions (for example Button[type="submit"])| Part | Role |
|---|---|
Form | Native form root that coordinates submit lifecycle and form-level errors. |
Composition
Use Form props such as errors, actionsRef, onFormSubmit, validationMode, action,
and native form attributes.
Compose fields with Field, FieldLabel, Input, FieldDescription, and FieldError.
Form does not hide service slots such as Positioner, Backdrop, or Viewport, so
customization is done directly through className on the form and composed child slots.
Examples
On Form Submit
Use onFormSubmit when you want form values as an object instead of reading FormData manually. Base UI prevents the native submit event for this handler.
import { type FormErrors, Form, Field, FieldLabel, Input, FieldError, Button,} from "moduix";import { useState } from "react";export function OnFormSubmitDemo() { const [errors, setErrors] = useState({} as FormErrors); const [submitting, setSubmitting] = useState(false); return ( <Form errors={errors} validationMode="onBlur" className={styles.form} onFormSubmit={(values) => { setSubmitting(true); const nextErrors = {} as FormErrors; const age = Number(values.age); if (!values.name?.trim()) { nextErrors.name = "Name is required."; } if (!values.age?.trim()) { nextErrors.age = "Age is required."; } else if (!Number.isFinite(age) || age < 18) { nextErrors.age = "Age should be 18 or greater."; } setErrors(nextErrors); setSubmitting(false); }} > <Field name="name"> <FieldLabel>Name</FieldLabel> <Input placeholder="Enter your name" /> <FieldError /> </Field> <Field name="age"> <FieldLabel>Age</FieldLabel> <Input type="number" placeholder="18" /> <FieldError /> </Field> <Button type="submit" loading={submitting}> Submit </Button> </Form> );}.form { width: min(20rem, 100%);}Actions Ref
Pass actionsRef when another control needs to trigger validation for one field or the whole form.
import { type FormActions, type FormErrors, Form, Field, FieldLabel, Input, FieldError, Button,} from "moduix";import { useRef, useState } from "react";export function ActionsRefForm() { const actionsRef = useRef(null as FormActions | null); const [errors, setErrors] = useState({} as FormErrors); return ( <Form actionsRef={actionsRef} errors={errors} validationMode="onSubmit" className={styles.form} onFormSubmit={(values) => { const nextErrors = {} as FormErrors; const email = String(values.email ?? ""); if (!email.trim()) { nextErrors.email = "Email is required."; } else if (!email.endsWith("@2gis.com")) { nextErrors.email = "Use a @2gis.com email."; } setErrors(nextErrors); }} > <Field name="email"> <FieldLabel>Work Email</FieldLabel> <Input type="email" required placeholder="name@2gis.com" /> <FieldError /> </Field> <Button type="button" variant="outline" onClick={() => { actionsRef.current?.validate("email"); }} > Validate Email </Button> <Button type="submit">Submit</Button> </Form> );}.form { width: min(20rem, 100%);}Action State
Use a form action with React useActionState when server or action results should feed back into field errors.
import { type FormErrors, Form, Field, FieldLabel, Input, FieldError, Button,} from "moduix";import { useActionState } from "react";interface ActionState { serverErrors?: FormErrors; message?: string;}async function submitUsername( _previousState: ActionState, formData: FormData,) { await new Promise((resolve) => { setTimeout(resolve, 500); }); const username = String(formData.get("username") ?? ""); if (!username.trim()) { return { serverErrors: { username: "Username is required.", }, }; } if (username.toLowerCase() === "admin") { return { serverErrors: { username: "'admin' is reserved for system use.", }, }; } return { serverErrors: {}, message: `Saved as ${username}.`, };}export function ActionStateForm() { const [state, formAction, loading] = useActionState( submitUsername, {} as ActionState, ); return ( <Form action={formAction} errors={state.serverErrors} validationMode="onBlur" className={styles.form} > <Field name="username"> <FieldLabel>Username</FieldLabel> <Input required defaultValue="admin" placeholder="e.g. alice132" /> <FieldError /> </Field> <Button type="submit" loading={loading}> Submit </Button> {state.message ? <p className={styles.helper}>{state.message}</p> : null} </Form> );}.form { width: min(20rem, 100%);}.helper { margin: 0; color: var(--color-muted-foreground); font-size: var(--text-sm); line-height: var(--line-height-text-sm);}Custom Styles
Pass className to the form root and every composed field, input, error, and button slot when styling with CSS Modules, Tailwind CSS, or CSS-in-JS.
import { Form, Field, FieldLabel, Input, FieldDescription, FieldError, Button,} from "moduix";export function CustomStylesFormDemo() { return ( <Form validationMode="onBlur" className={styles.customForm}> <Field name="project" className={styles.customField}> <FieldLabel className={styles.customLabel}>Project</FieldLabel> <Input required placeholder="Maps Platform" className={styles.customInput} /> <FieldDescription className={styles.customDescription}> Use the public project name. </FieldDescription> <FieldError className={styles.customError} match="valueMissing"> Please enter a project name. </FieldError> </Field> <Button type="submit" variant="outline" className={styles.customButton} > Submit </Button> </Form> );}.customForm { width: 20rem; max-width: 100%; gap: var(--spacing-3); padding: var(--spacing-4); border: var(--border-width-sm) solid color-mix(in srgb, var(--color-primary) 30%, transparent); border-radius: var(--radius-lg);}.customField { gap: var(--spacing-2);}.customLabel,.customError,.customButton { color: var(--color-primary);}.customInput,.customButton { border-color: color-mix(in srgb, var(--color-primary) 40%, transparent);}.customInput:focus { outline-color: var(--color-primary);}.customDescription { color: var(--color-muted-foreground);}