Building a form, step by step
From one field to a complete form. Each step adds exactly one idea, and nothing is skipped.
Seven steps, each one adding a single idea to the previous. If you follow it top to bottom you end up with an accessible, grouped, validated form and you will have written no wiring code.
Step 1 — one field
The smallest useful unit is a label, a control and the wrapper that connects them.
import { Field, FieldLabel, Input } from "@kanzo-tech/ui";
<Field>
<FieldLabel>Contract title</FieldLabel>
<Input placeholder="e.g. A wyrm under the granary" />
</Field>The label and input are already associated — clicking the label focuses the input. You did not
write an id.
Step 2 — explain the field
Two components do this, and the difference matters.
<Field>
<FieldLabel>Contract title</FieldLabel>
<Input />
<FieldDescription>One line — it is what the board shows.</FieldDescription>
</Field>FieldDescription— always-visible help text. Use it for guidance the user needs before they type. It ispointer-events-none, so it never steals a click from the control.FieldHelper— also help text, but wired into the control's accessible description. Use it when the text must be announced to a screen-reader user on focus.
If you are unsure, use FieldDescription.
Step 3 — mark it required
<Field required>
<FieldLabel>
Contract title
<FieldRequiredIndicator />
</FieldLabel>
<Input />
</Field>required goes on the root, and FieldRequiredIndicator reads it from context. The
indicator renders * by default and is aria-hidden, because the control is already marked
required for assistive tech — the asterisk is a visual convention, not the semantic signal.
Step 4 — show an error
<Field invalid={Boolean(error)} required>
<FieldLabel>Contract title</FieldLabel>
<Input />
<FieldError>{error}</FieldError>
</Field>This is the step people get wrong, so it is worth being explicit:
Leave FieldError in the tree unconditionally. Do not write
{error && <FieldError>…</FieldError>}. FieldError renders only while the field is invalid,
so the conditional is already handled — and doing it yourself is how a field ends up able to be
invalid without ever showing why. The single most common form bug in our own products is a
disabled submit button and no explanation anywhere on screen.
invalid on the root is what colours the label, tints the control, reveals the error, and sets
aria-invalid. One prop, four effects.
Step 5 — lay it out
orientation on the Field controls where the label sits:
vertical(default) — label above the control. Right for almost everything.horizontal— label and control on one line. Right for switches and compact settings panels.responsive— starts vertical, becomes horizontal when there is room.
<Field orientation="horizontal">
<FieldLabel>Post to every hall</FieldLabel>
<Switch />
</Field>responsive switches on a container query, not the viewport — and the container is
FieldGroup. A responsive field with no FieldGroup ancestor will never switch. This is
deliberate: a form inside a narrow side panel should stay stacked even on a wide screen.
Step 6 — group the fields
FieldGroup handles spacing and is the container-query root. FieldSet + FieldLegend add a
semantic grouping with a heading — use it whenever a set of fields shares a purpose.
<FieldSet>
<FieldLegend>Posting</FieldLegend>
<FieldDescription>What the contract pays, and who may claim it.</FieldDescription>
<FieldGroup>
<Field>
<FieldLabel>Reward</FieldLabel>
<Input />
</Field>
<Field>
<FieldLabel>Hall seal</FieldLabel>
<PasswordInput>
<PasswordInputGroup>
<PasswordInputInput hasStoredValue />
<PasswordInputTrigger />
</PasswordInputGroup>
</PasswordInput>
</Field>
</FieldGroup>
</FieldSet>FieldSet renders a real <fieldset> and FieldLegend a real <legend>, so the grouping is
announced, not just drawn. For radio and checkbox groups this is not optional — the legend is
what tells a screen-reader user what the options are for.
Step 7 — submit it
The library does not own form state, so this part is ordinary React (or your form library of choice — see Validation).
<form onSubmit={handleSubmit}>
<FieldSet>
<FieldLegend>Posting</FieldLegend>
<FieldGroup>{/* fields… */}</FieldGroup>
</FieldSet>
<Button type="submit" disabled={submitting}>
{submitting ? "Posting…" : "Post"}
</Button>
</form>A disabled submit button must never be the only signal that something is wrong. If the button is disabled, at least one field should be showing an error explaining why. Step 4 is what makes that automatic.
The finished shape
<form onSubmit={handleSubmit}>
<FieldSet>
<FieldLegend>Posting</FieldLegend>
<FieldDescription>What the contract pays, and who may claim it.</FieldDescription>
<FieldGroup>
<Field invalid={Boolean(errors.reward)} required>
<FieldLabel>
Reward
<FieldRequiredIndicator />
</FieldLabel>
<Input value={reward} onChange={(e) => setReward(e.target.value)} />
<FieldDescription>Gold, paid on delivery.</FieldDescription>
<FieldError>{errors.reward}</FieldError>
</Field>
<Field orientation="horizontal">
<FieldLabel>Post to every hall</FieldLabel>
<Switch checked={everyHall} onCheckedChange={setEveryHall} />
</Field>
</FieldGroup>
</FieldSet>
<Button type="submit">Post</Button>
</form>