Form
The anatomy layer above Field. Field solves one labelled control; Form owns everything around them: the form element and its rhythm, the chapters of a long form, the fieldset, and the bar that closes it.
Installation#
Anatomy#
Every part answers one question: how many controls does this wrap, and what is it to the reader? Get that right and the spacing, the labelling, and the ARIA all follow without a decision.
| Part | Wraps |
|---|---|
Form | The <form> element itself. Owns the vertical rhythm and the height of every control inside it. |
Field | One labelled control. Generates the id / htmlFor / aria-describedby wiring and cascades error and disabled state. |
FieldGroup | Several controls answering one question (a radio set, a checkbox cluster). A real fieldset + legend. |
FieldRow | Two or three fields that belong on one line. Stacks to a single column below sm. |
FormSection | A chapter of a long form, with its own title and description. Gets extra air from the chapter before it, and can set the header beside the fields with layout="split". |
FormActions | The bar that closes the form. Aligns the buttons, optionally rules itself off from the fields above. |
InputGroup | Several controls fused into ONE bordered box (a currency prefix, a url + select). Sits inside a Field, not beside one. |
The line that trips people up is Field versus FieldGroup. A single “I agree to the terms” checkbox is one control, so it is a Field. Three checkboxes picking email preferences answer one question, so they are a FieldGroup. And InputGroup is not a layout part at all: it fuses several controls into one bordered box, so it lives inside a Field where a single control would otherwise go.
Usage#
Form renders a plain form element, so action, onSubmit, and server actions all work exactly as they do on the native element. What it adds is the rhythm, so no form in your app has to invent its own gap.
import { Form, FormActions } from "@/components/ui/form"
import { Field, FieldLabel } from "@/components/ui/field"
<Form onSubmit={handleSubmit}>
<Field required>
<FieldLabel>Email</FieldLabel>
<InputRoot><InputField type="email" /></InputRoot>
</Field>
<FormActions>
<Button variant="ghost">Cancel</Button>
<Button type="submit">Continue</Button>
</FormActions>
</Form>Control size#
A form field is the thing being filled in, not a control tucked into a toolbar, so a Form sets its controls to lg (40px) by default. That is the top of the band the industry settled on: shadcn/ui ships a 36px input, Carbon's large is 48px, and touch guidance puts the comfortable range at 32 to 40px with a 44px hit area. 32px reads cramped for something you type into.
One prop retunes the whole form, fields and buttons together, so they can never mismatch. A control's own size still wins where you need one exception.
Reach for sm when the form is really a dense app surface: a filter bar above a table, a settings row inside a compact shell. Everything else, a signup, a checkout, a contact block, wants the default.
Rhythm and density#
Density and size are two different axes, and keeping them apart is what stops a form from drifting. size is how tall the controls are; density is how much space sits between them. The gap comes from the same axis that tunes every other gap in Koala: compact (the default) is 16px, comfortable is 20px.
Sections#
Once a form is long enough to have chapters, wrap each one in a FormSection. Sections keep the field rhythm inside themselves and take extra air from the section before them, so a chapter break reads as a break without you reaching for a margin.
FormSectionTitle renders an h3. When that is the wrong level for the page outline, pass asChild and bring your own heading: <FormSectionTitle asChild><h2>Shipping</h2></FormSectionTitle>.
layout="split" sets the header beside the fields instead of above them: the settings row you know from Stripe, Linear, and Tailwind UI. It stacks back to one column below md. Split sections need a FormSectionBody around the fields, because in a grid each field would otherwise claim a cell of its own instead of sharing the column.
A section is also the form body where a form element can't go: inside a Dialog the actions belong to DialogFooter, so there is nothing to submit. There it stays pure layout, which keeps the fields the same height as the dialog's own buttons. Pass size on the section if you want the taller form controls anyway, or to give one chapter a different height from the rest.
Field groups#
When several controls answer one question, a label has nothing to point at: there is no single control to name. FieldGroup is a real fieldset and FieldGroupLabel a real legend, which is the one construct that names a set of controls with no ARIA at all. The hint below works the same as in a Field, and describes the whole group.
hasError turns the hint into an error message, exactly as on a Field. disabled is the native fieldset attribute, so it disables every control nested inside without a single prop being passed down.
Do not add an aria-label to the control inside a group. The legend already names the set, and a second name only makes a screen reader say it twice.
Rows#
FieldRow puts fields that belong together on one line, and stacks them to a single column below sm so nothing gets cramped. It takes columns of 2 (the default) or 3.
Actions#
FormActions closes the form. end (the default) is the ghost + primary pairing; between pushes a destructive action away from the primary one; stack is the full-width submit a signup or checkout wants. Add bordered when the form is long enough that the bar needs a seam, and put a status message in as a first child with mr-auto.
One-line capture#
A newsletter or waitlist form is one field and one button, not a column. layout="row" is that shape: the field grows, the button hugs, and the pair stacks below sm. It is the same layout NewsletterForm and WaitlistForm use, so the three can never drift apart.
There is a third layout, plain, for when the form element is a card whose header, body, and footer sit flush against its borders. It contributes no rhythm at all, leaving a FormSection inside to own the field spacing. SettingsForm is built that way.
Validation: bring your own#
Form ships no validation, no schema prop, and no state. That is deliberate: most teams have already picked a form library or are using server actions, and a design system that insists on its own choice is one you have to fight. Field already exposes the surface validation needs, hasError and a FieldHint that doubles as the error message, so wiring any resolver is a few lines.
// react-hook-form
const { register, handleSubmit, formState: { errors } } = useForm()
<Form onSubmit={handleSubmit(onValid)}>
<Field required hasError={!!errors.email}>
<FieldLabel>Email</FieldLabel>
<InputRoot><InputField {...register("email", { required: true })} /></InputRoot>
<FieldHint>{errors.email ? "Enter a valid email." : "We'll send a receipt here."}</FieldHint>
</Field>
<FormActions><Button type="submit">Continue</Button></FormActions>
</Form>// Server action, no client library at all
const [state, action] = useActionState(subscribe, {})
<Form action={action}>
<Field required hasError={!!state.errors?.email}>
<FieldLabel>Email</FieldLabel>
<InputRoot><InputField name="email" type="email" /></InputRoot>
<FieldHint>{state.errors?.email ?? "We'll send a receipt here."}</FieldHint>
</Field>
<FormActions><Button type="submit">Continue</Button></FormActions>
</Form>