Design System
Patterns & Recipes

How do I build a form?

React Hook Form + Zod, via @digital-web-platform/forms.

Prescribed: React Hook Form + Zod, via @digital-web-platform/forms's useZodForm hook and its field primitives (Field, Input, Textarea, Select, RadioGroup, ErrorText, FormStatus, SubmitButton). Don't reach for a different form library, and don't hand-wire <input> + useState + your own validation — every piece below (label association, aria-describedby, aria-invalid, the required-field cue, busy/disabled submit state) is already solved and accessible.

This package currently has one real, production consumer — digital-gp (23+ files: login, guest details, dietary/accessibility forms, and their server-side schemas) — not yet cross-app proven, but it is genuinely battle-tested, not a sketch.

The approved implementation

useZodForm is a thin wrapper around React Hook Form's useForm — pass a Zod schema instead of a resolver, and it returns the real, unmodified UseFormReturn. Wrap each control in Field using the render-prop form (not a bare element) so the id/aria-describedby/aria-invalid wiring can't drift from the control:

The server side

Field/Input/etc. handle client-side validation, but a real form also needs to handle server-rejected input (the client and server schemas should be the same Zod schema, imported from a shared @digital-web-platform/forms/schema-style module, not duplicated by hand). This package's serverParse + applyServerErrors close that loop — parse the request body with the shared schema in your route handler, and feed a failure straight into the form's setError:

// app/api/<feature>/route.ts (server)
const parsed = serverParse(ContactSchema, await request.json());
if (!parsed.success) {
  return apiValidationError(parsed.formError, parsed.fieldErrors);
}

// the form component (client), on a failed submit response
applyServerErrors(setError, { formError, fieldErrors });

No production route handler exercises this exact contact-form schema yet in this repo — the snippet above is illustrative, shaped by the package's own tests and its real usage in digital-gp's login/guest-detail forms, not copied from a single working file.

Choosing a select control

Two packages own dropdowns, and which package you are in decides the answer.

In a brand-facing form built on @digital-web-platform/forms, Select is the whole choice — a native <select> styled from brand tokens, wired for label and error by Field.

In an internal app built on @the-armstrong-collective/ui-kit (RMS and the tools that follow it) there are two single-select dropdowns, and they are separate on purpose:

  • Select — short, fixed, known-at-build-time sets: a status, a sort direction, a currency. It renders a button and a listbox.
  • Combobox — long lists, or option sets fetched at runtime. It renders an ARIA 1.2 combobox — an editable text input with a popup listbox — so it can be filtered by typing.

They wear the same 44px field shell, so the decision is about interaction, not appearance. A combobox announces itself to a screen reader as an editable field and opens the keyboard on mobile, which is wrong for a three-option list; that is why there are two components rather than one with a searchable prop. For more than one value at a time, neither applies — use MultiSelect.

Only @the-armstrong-collective/ui-kit's Combobox fetches, so it is the only one of the three that can come back with nothing. It reports an empty response and an empty search as separate facts, which is a decision the owner has to make per field — see Combobox → States for the rule and for optionsNoun. The Select in @digital-web-platform/forms and the Select in @the-armstrong-collective/ui-kit both take a list that is already in hand, so neither has an empty-response state to report.

How wide is a dropdown?

In @the-armstrong-collective/ui-kit, Select, Combobox and MultiSelect size themselves: a minimum of 200px and a maximum of 300px, both held in fieldShell and applied by every dropdown. Don't restate either number in an app.

Both ends solve a real failure. Below 200px a dropdown in a tight column collapses to a stub too narrow to show the placeholder that says what it filters. Above 300px it stretches to whatever container it is in, so one sitting alone in a wide panel renders 900px across with the value at one end, the chevron at the other, and nothing in between — and it stops reading as a peer of the fields beside it. Extra width past 300px buys nothing: a label that doesn't fit at 300px is truncated at 900px too, just later.

Input is deliberately not capped. A text field shows whatever the user typed rather than a label drawn from a fixed list, so the width it is given is the width it can show, and there is no list to fall back on when the end is cut off.

Passing your own w-*, min-w-*, max-w-* or size-* in className stands both bounds down together — NumberedPager's w-20 rows-per-page trigger is the worked example. That is deliberate: CSS width beats neither min-width nor max-width, so a default that did not withdraw would silently ignore the caller. Because a caller who states a width has answered both questions, the floor and the cap withdraw as a pair rather than leaving you fighting whichever survived.

This is also why a filter row is a wrapping flex row rather than a fixed column grid: with each control sizing itself between the two bounds, controls sit inline and reflow by however many fit, instead of every control inheriting one column width and a short last row leaving holes.

Do / Don't

  • Do prefer the Field render-prop over passing a bare <input> as children — it's the only way the id/aria-describedby wiring is guaranteed to stay correct as the form changes.
  • Do share one Zod schema between the client form and the server route handler — that's the whole point of serverParse/applyServerErrors existing.
  • Don't roll your own spinner/disabled-state logic on a submit button — SubmitButton's busy prop already handles aria-busy, aria-disabled, and a reduced-motion-aware spinner.
  • Don't reach for a Combobox because the list might grow — pick on what the list is today. An editable field for three fixed options costs every user a keyboard they didn't need.

On this page