moduix

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.

Base UI API

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.

PropertyDefaultDescription
--form-gapvar(--spacing-4)Controls spacing between form children.
--form-max-widthnoneControls the root form max width.
--form-width100%Controls the root form width.

Interactive variables scoped for docs preview without changing size scale tokens.

PropertyValueDefaultDescription
--form-gapvar(--spacing-4)Controls spacing between form children.
--form-max-widthnoneControls the root form max width.
--form-width100%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"])
PartRole
FormNative 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.

Use the public project name.

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);}

On this page