Form
Apply consistent structure to a native HTML form without hiding submission or validation logic.
Overview
Form is a thin wrapper around the native HTML form element — it renders <form> and forwards every prop unchanged.
Use it to keep Honest UI form markup consistent while your application continues to own submission, validation, pending state, server errors, and success feedback. Nothing is registered or submitted behind an abstraction: because it is a native form, browser constraint validation still runs unless you opt out, values are submitted under each control's name, and server actions passed to action work as they would on any <form>.
Anatomy
A form contains one native form root, its fields, and the actions that submit or reset it. Combine Form with Field and Fieldset when controls need labels, descriptions, grouped questions, or validation messages.
Behavior
Form accepts the same props as a native <form>, including action, method, onSubmit, and noValidate. It does not collect values into an object or distribute server errors. Read values with FormData, a server action, or the form library your project already uses.
Because the wrapper is native, Field's submit-time machinery does not run automatically: wire per-field error display through Field's validationMode, validate, or invalid props, and let the browser's own constraint validation handle required and types when that is enough. See Don't do this for the most common way this goes wrong.
Keep entered values in place after an error. Show pending state on the submit action, associate field errors with their controls, and place a visible status message where users can confirm the result.
Accessibility
Use a submit button with type="submit"; Honest UI buttons otherwise default to type="button" and pressing Enter will not submit anything. Give every control a visible label, describe validation requirements before submission, and move focus only when users need to correct a specific problem — focus the first invalid field after a failed submission rather than restarting the page.
Announce asynchronous status with a persistent visible message plus aria-live="polite" (or role="status"), so screen reader users hear the outcome without the message disappearing from the screen.
Installation
Usage
import {
Field,
FieldControl,
FieldError,
FieldLabel,
} from "@/components/ui/field";
import { Button } from "@/components/ui/button";
import { Form } from "@/components/ui/form";<Form
onSubmit={(e) => {
e.preventDefault();
const values = new FormData(e.currentTarget);
saveEmail(values.get("email"));
}}
>
<Field>
<FieldLabel>Email</FieldLabel>
<FieldControl name="email" type="email" required />
<FieldError>Please enter a valid email.</FieldError>
</Field>
<Button type="submit">Save email address</Button>
</Form>Don't do this
Collecting values control by control
// Bad
const emailRef = React.useRef<HTMLInputElement>(null);
const nameRef = React.useRef<HTMLInputElement>(null);
<Form onSubmit={() => save(emailRef.current?.value, nameRef.current?.value)}>// Good
<Form
onSubmit={(event) => {
event.preventDefault();
const values = new FormData(event.currentTarget);
save(values.get("email"), values.get("fullName"));
}}
>Per-control refs break the moment a field moves, becomes conditional, or gains a wrapper, and they silently drop every field someone adds later without a ref. FormData reads exactly what the browser would submit, keyed by name, including dynamically added fields.
Submitting without feedback
// Bad
<Form onSubmit={(event) => {
event.preventDefault();
fetch("/api/subscribe", { method: "POST" });
}}>// Good
<Form onSubmit={handleSubscribe}>
{/* ...fields... */}
<Button type="submit" disabled={pending}>
{pending && <LoaderCircleIcon className="animate-spin" aria-hidden="true" />}
Subscribe
</Button>
<p role="status">{status}</p>
</Form>A fire-and-forget request leaves people staring at an unchanged form: no confirmation on success, no recovery path on failure, and a button that can be clicked five more times while the first request is still flying. Disable the action while pending, keep its label stable with a spinner, and report the outcome in a live region.
Examples
The first example shows native submission with visible, in-page feedback. Zod remains an optional application dependency in the second example; Form does not require or configure it.
Using with Zod
Client-side validation mapped back onto named fields.
Newsletter capture
Structure and copy only; your application supplies the submission handler.
Server-side error mapping
Errors returned from the server are keyed by field name and routed to the matching Field through its invalid prop, while entered values stay in place.
"use client";
import * as React from "react";
import { LoaderCircle as LoaderCircleIcon } from "honestui/icons";
import { Button } from "@/components/ui/button";
import {
Field,
FieldControl,
FieldError,
FieldLabel,
} from "@/components/ui/field";
import { Form } from "@/components/ui/form";
const TAKEN_USERNAMES = ["admin", "support", "root"];
type ServerErrors = Partial<Record<"username", string>>;
async function submitUsername(username: string): Promise<ServerErrors> {
await new Promise((resolve) => setTimeout(resolve, 700));
if (TAKEN_USERNAMES.includes(username.toLowerCase())) {
return { username: `${username} is already taken. Try another one.` };
}
return {};
}
export default function FormServerError() {
const [errors, setErrors] = React.useState<ServerErrors>({});
const [pending, setPending] = React.useState(false);
const [status, setStatus] = React.useState("");
const onSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const username = String(formData.get("username") ?? "");
setErrors({});
setPending(true);
const serverErrors = await submitUsername(username);
setErrors(serverErrors);
setPending(false);
setStatus(
serverErrors.username
? "The name was rejected. Pick another and resubmit."
: `Reserved ${username}.`,
);
};
return (
<Form onSubmit={onSubmit} className="grid w-full max-w-64 gap-4">
<Field invalid={Boolean(errors.username)}>
<FieldLabel>Username</FieldLabel>
<FieldControl
name="username"
placeholder="ada"
autoComplete="off"
onChange={() => {
setErrors((current) =>
current.username ? {} : current
);
}}
/>
<FieldError>{errors.username}</FieldError>
</Field>
<Button type="submit" disabled={pending}>
{pending && (
<LoaderCircleIcon className="animate-spin" aria-hidden="true" />
)}
Reserve username
</Button>
<p aria-live="polite" className="min-h-[var(--hui-space-5)] text-[length:var(--hui-font-size-mini)] text-[var(--hui-color-foreground-base-secondary)]">
{status}
</p>
</Form>
);
}
API reference
Form accepts React.ComponentProps<"form"> and forwards every prop to the native element. It adds no custom props or submission behavior.
Native props worth reaching for deliberately:
See the MDN form documentation for everything else the native element supports.