Kanzo UI
Forms

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 is pointer-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

Posting

What the contract pays, and who may claim it.

Gold, paid on delivery.

<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>

Next: every control and how it composes.

On this page