@k8ordo/ui

Forms

Spread the input that @k8ordo/form derives for a field straight onto @k8ordo/ui’s inputs. You also learn which component fits each schema and what else to pass.

On this page

Spreading input

talk-form.tsx
const title = form.field('title');

<FormControl
  errorText={title.error}
  invalid={title.invalid}
  label="Title"
  required={title.required}
  renderInput={(props) => <TextField {...props} {...title.input} />}
/>

form.field(name).input holds the attributes @k8ordo/form derives from the schema, such as type and required. How useForm works is covered in @k8ordo/form’s Get Started.

Give FormControl only label, errorText, invalid and required; it creates the id and the aria-* links itself. Spread input after the props FormControl hands over, with nothing taken out first. TextField renders the date and time types as well, and Textarea drops the type a <textarea> does not have.

Every component works uncontrolled, with its value held in the input element. A reset puts it back to defaultValue, and isDirty compares against defaultValue. A value a component changes in code, such as a stepper or a chosen option, fires an input event the way typing does.

Components by schema

For each schema type, the components that fit and what to pass besides {...props} {...input}.

  • z.string(), z.email(), z.url(), z.iso.*(): TextField or Textarea, with nothing else to pass.
  • A password: PasswordInput. Its show/hide toggle still works after type is spread.
  • z.coerce.number(): NumberField or Slider. They accept string min, max and step="any".
  • z.enum([…]): Select, Radio or RadioCard. Pass options, and labelAs="legend" on FormControl for radios.
  • z.boolean(), z.literal(true): Checkbox or Switch. Pass a label; they need no FormControl.
  • z.array(z.enum([…])): CheckboxGroup.Root, CheckboxCard or Autocomplete. Pass defaultValue.
  • z.file(): FileField.Root, with a FileField.Trigger and a FileField.ItemList inside.

Choice components

tags-field.tsx
const tags = form.field('tags');
const checked = state.values?.tags;

<FormControl
  errorText={tags.error}
  invalid={tags.invalid}
  label="Tags"
  labelAs="legend"
  renderInput={(props) => (
    <CheckboxCard
      {...props}
      {...tags.input}
      defaultValue={Array.isArray(checked) ? checked : []}
      options={options}
    />
  )}
/>

A checkbox group’s value comes back in state.values as an array. input carries only the name, so pass that array as defaultValue. minChecked applies to Autocomplete too. It submits through a hidden <select multiple> that stays even with nothing selected, and the error is attached to that element. Focus sent there after a failure moves on to the text input.

Spread input onto Radio and RadioCard directly. They read defaultValue as the selected option and put required on every radio.

For required to take effect on a Select, put { value: '', label: '…' } first in options. A <select> without an empty option always has a choice selected.

Caveats

Warning

Pitfall

A spread value does not reach the <input> inside Checkbox, so a z.stringbool() field drawn with it submits the browser’s on. An unconfigured z.stringbool() reads on as true. With a custom truthy, the submitted on no longer fits the schema, and it differs from the value @k8ordo/state writes to the URL. Render that field as a plain <input {...field.input} />.

NumberField renders type="text" so it can format and step the value, and the browser does not check min and max on a text input. NumberField reports an out-of-range value itself with setCustomValidity. That message comes from @k8ordo/ui’s dictionary, not from zod. This is the only place the client’s text is not zod’s own.

A regex on a field rendered as a Textarea reaches the markup as pattern, but a <textarea> ignores that attribute. The check runs on the server only. formFields does not know which element renders the field, so dropped cannot list it either.

FileField submits what FileField.ItemList shows. A file removed from the list leaves the submission, and a reset empties the list.

Form-level error

talk-form.tsx
{form.formError.message !== undefined && (
  <Alert
    {...form.formError.props}
    message={form.formError.message}
    tone="error"
  />
)}

form.formError can be shown with Alert. Alert passes id and tabIndex through to its element, so spreading form.formError.props lets it take focus.

Alert has role="alert", so a screen reader may read the message twice: once when it appears, once when focus lands on it. An error summary with an alert role behaves the same way.

k8ordo

React libraries that use Baseline features without holding back.

© 2026 k8o — MIT License

Typeset in Noto Sans JP & M PLUS 2