Form
ProThe 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, and the validation of the Fields inside it.
The preview is yours to explore. Its code comes with a Pro license, paid once and used in every project.
Have a license? Unlock it here
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#
Each Field checks its control's native constraints, then its own validate. The Form decides when. On a failed submit it blocks the submission, focuses the first invalid control and shakes it, with no browser bubble. Field covers the per-field side: matching messages, custom and async checks.
validationMode sets the timing. onSubmit (the default) stays quiet until the first submit, then re-checks each field as it changes. onBlur checks a field when focus leaves it. onChange checks on every keystroke.
onFormSubmit receives the values as an object keyed by field name, and only runs when everything passes. A plain onSubmit still works, and also runs only once the form is valid. Pass noValidate to skip the native constraints and keep only your validate functions.
Server errors#
Pass errors, keyed by field name. Each one shows in its field and clears as soon as that value changes. It is the shape a Server Action returns, so useActionState plugs straight in. Submit as is to see both errors.
Client checks run first, so the action only fires for a form that already passes. A new set of errors focuses the first field it names.
With a form library#
A library that owns its own state drives the Field through hasError, which wins over anything the Field computed, and puts the message in a FieldError. Pass noValidate so the library alone decides.
The preview is yours to explore. Its code comes with a Pro license, paid once and used in every project.
Have a license? Unlock it here
The preview is yours to explore. Its code comes with a Pro license, paid once and used in every project.
Have a license? Unlock it here
API reference#
Form
A plain form element that forwards every native prop (action included). Owns the rhythm, the control height and the validation of the Fields inside.
| Prop | Type | Default | Description |
|---|---|---|---|
| size | "sm" | "md" | "lg" | "lg" | Height of every control inside. A control's own size still wins. |
| density | "compact" | "comfortable" | - | The gap between fields: 16 or 20px. Falls back to the density provider. |
| layout | "stack" | "row" | "plain" | "stack" | A field column, a one-line capture, or no rhythm for a form that is itself a card. |
| validationMode | "onSubmit" | "onBlur" | "onChange" | "onSubmit" | When fields check themselves. Every mode validates the whole form on submit. |
| validationDebounceTime | number | 0 | Wait this many ms after the last change before validating. |
| errors | Record<string, string | string[]> | - | Server errors keyed by field name. Each clears when its field changes. |
| onFormSubmit | (values: FormValues, event) => void | - | Called with the values, keyed by name, only when every field is valid. Prevents the native submit. |
| onSubmit | (event) => void | - | The native handler. Only called once validation passes. |
| noValidate | boolean | - | Skip native constraints. validate functions still run. |
FormSection
A chapter of a long form. Renders a section.
| Prop | Type | Default | Description |
|---|---|---|---|
| layout | "stack" | "split" | "stack" | Header above the fields, or beside them (needs FormSectionBody). |
| density | "compact" | "comfortable" | - | The rhythm inside the chapter. |
| size | "sm" | "md" | "lg" | - | A different control height for this chapter. |
FormSectionHeader
Stacks the title and description.
FormSectionTitle
The chapter heading, an h3.
| Prop | Type | Default | Description |
|---|---|---|---|
| asChild | boolean | false | Render your own heading element. |
FormSectionDescription
One line under the title.
FormSectionBody
The field column of a split section.
| Prop | Type | Default | Description |
|---|---|---|---|
| density | "compact" | "comfortable" | - | The rhythm between fields. |
FormActions
The bar that closes the form.
| Prop | Type | Default | Description |
|---|---|---|---|
| align | "start" | "end" | "between" | "stack" | "end" | Where the buttons sit. stack is the full-width submit. |
| bordered | boolean | false | Rules the bar off from the fields above. |