Form — spec¶
Status: example artifact. The Form is a composition pattern, not a single component — it specs how Input/Select/Checkbox/etc. compose into a coherent form. Generated by:
skills/component-spec-writer, citing Ant DesignForm, MUI form patterns, shadcn-uireact-hook-formintegration
Purpose¶
A Form coordinates multiple inputs into a single submission. It owns: layout, label/error wiring, validation orchestration, submission state, and progressive disclosure. Get this wrong and every individual input feels disconnected.
When this is a Form vs. just inputs¶
| Use Form | Use loose inputs |
|---|---|
| Submission to a server with success/error | A single search input |
| Coordinated validation across fields (e.g., password match) | Independent settings toggles |
| Multi-step | Inline edit on a profile page |
| Required validation + "save changes" gate | Filter inputs that update results in real-time |
Composition¶
<Form onSubmit={handleSubmit}>
<Form.Field name="email">
<Form.Label>Email</Form.Label>
<Form.Control>
<Input type="email" />
</Form.Control>
<Form.HelpText>We'll only use for password recovery.</Form.HelpText>
<Form.ErrorText />
</Form.Field>
<Form.Field name="password" required>
<Form.Label>Password</Form.Label>
<Form.Control>
<Input type="password" />
</Form.Control>
<Form.ErrorText />
</Form.Field>
<Form.Footer>
<Button type="submit" loading={form.formState.isSubmitting}>
Sign in
</Button>
</Form.Footer>
</Form>
| Slot | Required | Notes |
|---|---|---|
Form |
yes | Owns the state, validation, submission |
Form.Field |
for each input | Wraps one logical field. Wires id/aria automatically. |
Form.Label |
yes | Visible label, htmlFor auto-set |
Form.Control |
yes | Slot for the actual input element |
Form.HelpText |
optional | Below-input hint |
Form.ErrorText |
yes | Below-input error (replaces help text on error) |
Form.Footer |
usual | Submit/cancel buttons |
Form.Section |
optional | Logical group of fields with a heading |
API¶
<Form> props¶
| Prop | Type | Default | Description |
|---|---|---|---|
onSubmit |
(values: T, e: FormEvent) => void \| Promise<void> |
— | Called with validated values. If returns a Promise, form is in submitting state until it resolves. |
defaultValues |
Partial<T> |
{} |
Initial values for uncontrolled fields. |
validate |
(values: T) => Errors \| Promise<Errors> |
— | Custom validator. Use Zod schema for the typical case. |
schema |
ZodSchema<T> |
— | Zod schema. Auto-validates per field on blur and on submit. |
mode |
"onSubmit" \| "onBlur" \| "onChange" \| "all" |
"onBlur" |
When to validate. |
onError |
(errors: Errors) => void |
— | Called when validation fails on submit. |
<Form.Field> props¶
| Prop | Type | Default | Description |
|---|---|---|---|
name |
string |
— | Required. The key in the form values object. Supports dotted paths (address.city). |
required |
boolean |
false |
Marks visually + sets aria-required. |
disabled |
boolean |
false |
Disables the input AND its label/help styling. |
Layout¶
Single-column rule¶
See knowledge/patterns/form-design.md — single-column over multi-column unless the fields are genuine pairs.
Sections¶
<Form>
<Form.Section title="Contact">
<Form.Field name="email">...</Form.Field>
<Form.Field name="phone">...</Form.Field>
</Form.Section>
<Form.Section title="Shipping address">
<Form.Field name="address">...</Form.Field>
<Form.Field name="postalCode">...</Form.Field>
</Form.Section>
</Form>
Section renders a <fieldset> with <legend> for accessibility. Visual: heading + thin divider above, no heavy box.
Spacing¶
- Between fields:
--space-base(16px) - Between sections:
--space-2xl(32px) - Field internal vertical (label → input → help):
--space-xs(4px)
Validation orchestration¶
Use react-hook-form + zod (this is the modern de facto standard; both Ant and shadcn ecosystems converge here). MUI's react-hook-form adapter is well-maintained too.
Schema pattern (recommended)¶
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
const schema = z.object({
email: z.string().email("올바른 이메일 형식이 아닙니다"),
password: z.string().min(8, "8자 이상 입력해 주세요"),
agreeToTerms: z.literal(true, { errorMap: () => ({ message: "약관 동의가 필요합니다" }) }),
});
type FormValues = z.infer<typeof schema>;
function SignupForm() {
const form = useForm<FormValues>({
resolver: zodResolver(schema),
mode: "onBlur",
});
const onSubmit = async (values: FormValues) => {
await api.signup(values);
};
return <Form form={form} onSubmit={onSubmit}>...</Form>;
}
Validation timing matrix¶
| Trigger | What |
|---|---|
| User typing | Format only, no errors |
| User leaves field (blur) | Validate that field. If error, show. |
| User returns to errored field, types | Validate on every keystroke (live correction) |
| User submits | Validate all fields. Set focus to first errored field. |
Cross-field validation¶
Password confirmation, date range, conditional required:
const schema = z.object({
password: z.string().min(8),
passwordConfirm: z.string(),
}).refine((data) => data.password === data.passwordConfirm, {
message: "비밀번호가 일치하지 않습니다",
path: ["passwordConfirm"],
});
But also: skip the password-confirm field entirely. See knowledge/patterns/form-design.md Don'ts.
Submission states¶
| State | Trigger | UI |
|---|---|---|
| Idle | resting | Submit enabled |
| Validating (async) | username availability check | Field-level spinner |
| Submitting | onSubmit runs (Promise pending) | Submit button: loading=true, disabled=true. All inputs disabled. |
| Success | Promise resolved | Toast "저장되었습니다", optionally navigate or close form |
| Error | Promise rejected | Toast or inline error at top of form. Inputs re-enabled. |
Server-side errors mapping¶
try {
await api.signup(values);
} catch (err) {
if (err.code === "EMAIL_ALREADY_EXISTS") {
form.setError("email", { message: "이미 가입된 이메일입니다" });
form.setFocus("email");
} else {
toast.error("저장에 실패했습니다", { description: err.message });
}
}
Map server errors to specific fields when possible. Generic toast only for non-field-specific failures.
Multi-step forms¶
For 8+ fields with logical grouping:
<Form schema={fullSchema} defaultValues={values}>
<Form.Steps>
<Form.Step title="Account">
<Form.Field name="email">...</Form.Field>
<Form.Field name="password">...</Form.Field>
</Form.Step>
<Form.Step title="Profile">
<Form.Field name="name">...</Form.Field>
<Form.Field name="phone">...</Form.Field>
</Form.Step>
<Form.Step title="Preferences">
<Form.Field name="locale">...</Form.Field>
</Form.Step>
</Form.Steps>
<Form.Footer>
<Form.PrevButton>Back</Form.PrevButton>
<Form.NextButton>Next</Form.NextButton>
<Form.SubmitButton>Sign up</Form.SubmitButton>
</Form.Footer>
</Form>
Required affordances:
- Progress indicator: dots, numbered steps, or progress bar at top
- Per-step validation: can't advance with errors
- Back button: always available except on first step
- Save progress: persist defaultValues to localStorage on every blur
- Don't reset on back navigation
Accessibility¶
<form>element wraps;<button type="submit">triggers- Each
Form.Fieldauto-wires: idon input<label htmlFor>on labelaria-describedbyto HelpText idaria-invalid="true"on erroraria-describedbyextends to ErrorText id when erroraria-required="true"whenrequired- ErrorText in
role="alert"for assertive announcement - On submit-fail: focus moves to first errored field
- Form-level errors (e.g., "Network error") announced via
role="status"oraria-live="polite"region - For long forms: provide an in-form error summary at top with anchor links to each field
Tokens consumed¶
--space-xs (label → input gap)
--space-base (between fields)
--space-2xl (between sections)
--color-error (error states)
--color-text-secondary (help text)
--color-text-tertiary (optional indicator)
(Plus all tokens consumed by Input, Select, etc. via the Form.Control slot.)
Edge cases¶
- Browser autofill conflict with controlled state: react-hook-form handles this via
register, but Controller-based fields needsetValueon autofill events. Test with 1Password. - Form inside Modal: focus on first field on modal open (not close button). On submit-fail, ensure error focus stays inside modal.
- iOS keyboard pushing submit button off-screen: see knowledge/platforms/react-native.md keyboard handling. Web: scroll-into-view on focus + sticky footer.
- Pasting whole "Name Address Phone" into one field: not Form's job to split — but offer help text "이름, 주소, 연락처를 따로 입력해 주세요" if you anticipate this.
- Korean IME composition: don't validate during composition. Listen to
compositionstart/end. - Optimistic update: for "save profile name" with server save in background — update UI immediately, queue server call, surface error if server rejects (rare).
Don't¶
- Don't disable submit until all fields valid. Show errors specifically — see knowledge/patterns/form-design.md.
- Don't validate on every keystroke before user has tried. Wait for blur.
- Don't show server-side errors as global toast when they're field-specific.
- Don't lose progress when user navigates back from a step.
- Don't ship a confirm-email or confirm-password field. Use show/hide toggle and a verification email instead.
References¶
- Ant Design:
refs/ant-design/components/form/— exhaustiveForm,Form.Item,Form.List,Form.Provider. Most complete API. Built-in validation rules. Ant'sForm.useFormis the imperative escape hatch. - MUI: composition with
<form>+react-hook-form+<TextField>. No dedicated Form component — MUI explicitly defers form orchestration to RHF. - shadcn-ui:
refs/shadcn-ui/apps/v4/registry/new-york-v4/ui/—form.tsxwraps RHFFormProvider+Controller+ auto-wiresaria-*. Cleanest minimal version. Mandates Zod + RHF.
API choices made:
- Composition (Form.Field/Form.Label/Form.Control) over Ant's Form.Item prop-blob — slot-based scales to custom field types better.
- schema prop accepting a Zod schema as the default validator — modern teams converge here. Custom validate is the escape hatch.
- mode="onBlur" default, not onChange. Less anxiety-inducing during typing.
- Auto-wires aria-: matches shadcn's pattern, removes the most-skipped a11y wiring.
- *Form.Steps for multi-step** rather than a separate <Wizard> component — keeps validation/state co-located.
Cross-reference¶
- knowledge/patterns/form-design.md — patterns this spec implements
- examples/component-input.md — what goes inside
Form.Control - knowledge/i18n/korean-product-conventions.md — Korean form expectations (address, phone, marketing consent)
- knowledge/a11y/keyboard-and-focus.md — focus on submit-fail