Form
A layout shell for a set of fields. It gives them one vertical rhythm, an actions row, and a fixed position where the app puts the submit result. It owns no form state, dictates no form-state library, and paints nothing of its own.
Parts
Two exports: Form and FormActions. There is no FormField — every field family component already owns its own label, id linkage, and error message, so there is nothing left for a wrapper to wrap. Put fields in as plain children.
Form renders a real <form> and spreads native props, so onSubmit, action, method, and id behave exactly as they do on the element. The result is a result prop rather than a child, because the shell owns where it appears and a child cannot guarantee its own position.
Rhythm
One fixed step between children — 20px, on a flex column. There is no spacing or density prop. Removing the decision is the point: every form in an app agrees without anyone copying a number. Fields with labels above need visibly more separation than the label-to-input distance inside a field, and error messages grow in on top of that. A heading dropped between two fields is another child and inherits the same step. A genuinely dense form is its own design decision, not a prop retrofitted here.
Actions row
FormActions stacks its buttons full width with the primary on top below 640px and lays them out in a right-aligned row above it — the same shape as the dialog footer. It sits in the normal flow as another child at the same rhythm step: no top border, no extra separation. It is optional; a one-button form can put the button in directly.
Write type="submit" on the submit button. Button defaults to type="button", so a Cancel button next to it never submits by accident. FormActions injects nothing into its children — it would have to inspect and clone them, and that breaks the moment a button is wrapped.
Live submit
Submit either panel below. The handler waits 1.6 seconds on purpose so the button's morph is visible. The submit button's loading is the only busyness a submitting form shows, and the fields stay editable while the request is in flight — so a typo can still be fixed. Form exposes no pending and no form-level disabled; every field already takes its own disabled.
Field errors
Per-field failures belong to the field. Each one takes an error string and owns the whole failure itself: the label, the border, the ring, and the message below. Form neither collects them nor renders a summary, and it never reads error state from a context.
The result slot
Pass the app's own Alert to result and it renders below the actions row, as the last child of the column, at the same rhythm step. One position, so the button the user just pressed never moves. The slot is a position, not a renderer: the app supplies the alert and picks its variant, because only the app knows what a successful submit means. Form-wide failures — cross-field validation, a server that is the validator — go through the same slot.
The slot reserves no space when it is empty, carries no live region, and Form does no scroll or focus management: the alert announces itself through its own role. A dialog form leaves the slot empty — it closes on submit, so its result belongs on the item that changed.
Wiring a form-state library
Form ships no binding and depends on no form-state library. A design system is a rendering stack; form state is app logic, and dictating a library narrows who can adopt this one. Every example on this page runs on plain React state.
The field family's props are the contract any library drives. Each member takes name, error as a single string, disabled, and its own change callback — native onChange on input and textarea, onCheckedChange on checkbox, onValueChange on select and radio-group. Wire a library's per-field state onto those four and it works; reducing that library's error shape down to the one string is app code, and so is subscribing to its submitting flag for the button's loading. No schema library is named here either: a Standard Schema issue, a plain string, and a library's own error type all reduce to the same string.
Motion
None of Form's own. The two things that move already animate themselves: a field's error message and the result alert each grow their own height from zero on spring-settle, so the content below them travels continuously rather than snapping. A layout animation on the root would double-animate the same shift. The submit button runs its own morph into loading. The rhythm uses flex gap rather than sibling margins, whose collapsing fights those height animations.
Accessibility
Form sets noValidate by default, and it is overridable. The field's error string is meant to be the only error channel: native constraint validation would add a second one, rendered as a browser bubble positioned by the user agent, unowned and gone on the next click.
Because the root is a real <form>, Enter in a text field submits through native implicit submission and Enter in a textarea inserts a newline. Tab order is DOM order and Form adds nothing to it. There is no Cmd or Ctrl+Enter shortcut — a hidden keybinding with no visible affordance only helps people who already guessed it exists, and Tab then Enter on the submit button already works. Form paints no background, border, or text, so it carries no contrast obligation of its own; the fields, the buttons, and the result alert each meet AA on their own.