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.

Contact

Where we send the receipt.

We'll only use it for this order.

Delivery

Delivery speed

Installation#

# One-time setup (tokens + lib helpers)npx koalaui-cli@latest init# Add this component (its dependencies come along)npx koalaui-cli@latest add form

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.

PartWraps
FormThe <form> element itself. Owns the vertical rhythm and the height of every control inside it.
FieldOne labelled control. Generates the id / htmlFor / aria-describedby wiring and cascades error and disabled state.
FieldGroupSeveral controls answering one question (a radio set, a checkbox cluster). A real fieldset + legend.
FieldRowTwo or three fields that belong on one line. Stacks to a single column below sm.
FormSectionA 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".
FormActionsThe bar that closes the form. Aligns the buttons, optionally rules itself off from the fields above.
InputGroupSeveral 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.

sm (32px)

md (36px)

lg (40px)

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.

compact

comfortable

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.

Account

How you sign in.

Profile

Shown on your public page.

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.

Profile

This information is shown to your teammates.

Workspace

Pick a name for this workspace.

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.

Email preferences(required)

Pick at least one. You can change this any time.

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.

Delivery speed(required)

Choose a delivery speed to continue.

Delivery speed

Delivery is set by your plan.

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.

end (default)

between

stack

bordered

All changes saved

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-formconst { 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 allconst [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>

FAQ#

Count the controls that answer the question. One control is a Field, so a single 'I agree to the terms' checkbox is a Field. Several controls answering one question is a FieldGroup, so a radio set or a cluster of preference checkboxes is a FieldGroup. The difference matters for more than spacing: a group has no single control for a label to point at, which is why FieldGroup renders a real fieldset and legend.