Skip to content
Open Cloud

Custom Fields ​

A custom field component replaces the whole field. Map it by type in components:

vue
<FormRenderer :definition="definition" :components="{ 'x-choice': ChoiceButtons }" @submit="onSubmit" />
tsx
<FormRenderer definition={definition} components={{ 'x-choice': ChoiceButtons }} onSubmit={handleSubmit} />

The same map overrides a built-in type, such as { date: MyDatePicker }. See Custom field types for naming and props.

Field types go in components. Sections, groups, the error summary, the progress, the buttons and every other part around the fields go in layout. components.section, components.group, components.groupItem and components.confirmDialog still work, and warn once in development with the code legacy-layout.

To plug in a whole design system, start with Design systems: mapControls maps one control per type and one shell for the label, helper and error.

Before you write a component, read Value types: each built-in type expects one value shape, and a scale reads its points from validation, not from options. See Scale.

Field helpers ​

fieldProps(props) in React and useFieldProps(props, emit) in Vue return everything a field needs from the contract: ids, ARIA, flags, the value and the handlers. A text field is then a few lines:

vue
<script setup lang="ts">
import { fieldComponentEmits, fieldComponentProps, useFieldProps } from '@formhaus/vue';

const props = defineProps(fieldComponentProps);
const emit = defineEmits(fieldComponentEmits);
const f = useFieldProps(props, emit);
</script>

<template>
  <div>
    <label :for="f.id">{{ f.label }}</label>
    <input v-bind="f.attrs" :type="f.inputType" :value="f.text" @input="f.onInput" />
    <p v-if="f.helperText" :id="f.helperId">{{ f.helperText }}</p>
    <p v-if="f.error" :id="f.errorId">{{ f.error }}</p>
  </div>
</template>
tsx
import { fieldProps, type FieldComponentProps } from '@formhaus/react';

export function TextInput(props: FieldComponentProps) {
  const f = fieldProps(props);
  return (
    <div>
      <label htmlFor={f.id}>{f.label}</label>
      <input {...f.attrs} type={f.inputType} value={f.text} onChange={f.onInput} />
      {f.helperText && <p id={f.helperId}>{f.helperText}</p>}
      {f.error && <p id={f.errorId}>{f.error}</p>}
    </div>
  );
}

export function ChoiceButtons(props: FieldComponentProps) {
  const f = fieldProps(props);
  return (
    <fieldset id={f.id} data-fh="field" {...f.aria}>
      <legend>{f.label}</legend>
      {f.options.map((option) => (
        <button
          key={option.value}
          type="button"
          aria-pressed={f.value === option.value}
          disabled={f.disabled}
          onClick={() => f.commit(option.value)}
        >
          {option.label}
        </button>
      ))}
      {f.error && <p id={f.errorId}>{f.error}</p>}
    </fieldset>
  );
}
KeyMeaning
id, nameThe element id, which is the id prop or a default from the key, and the field key
label, helperText, error, placeholderThe texts to show. placeholder falls back to mask
helperId, errorId`${id}-helper` and `${id}-error`
describedByThe helper id and then the error id, only the ones shown
invalid, required, disablederror is set, validation.required is set, disabled or loading is set
minLength, maxLengthvalidation.minLength and validation.maxLength of a text, email, phone, password or textarea field, for a character counter. The input does not get them as attributes, so typing past the limit shows the error instead of cutting the text
inputType, inputModeThe type of a native input for the field type (text, email, tel, number, password, date, time or datetime-local), and field.inputMode
ariaaria-invalid, aria-required and aria-describedby, for a control or group whose helper and error you render
testIdThe test id of the field
value, textThe value, and the value as a string with '' for null
change(value)Saves a value. For number, decimal text becomes a number, empty text becomes null, other text stays a string
onInput(event)change with the value of an input event
commit(value)A discrete pick: commits with autoAdvance, else saves
blur(), focus()Report blur and focus
optionsmatrix.columns in a matrix row, else field.options
option(option, index)id, name, value, checked, disabled, data-testid and the blur and focus handlers of one radio or checkbox. The first option gets id, the others `${id}-${index}`
attrsFor a native control whose helper and error you render: id, name, disabled, placeholder, inputMode, autoComplete (Vue: autocomplete), data-testid, aria and the blur and focus handlers
inputFor a component that renders its own label, helper and error: attrs without aria-describedby and data-testid, plus the value. Keep aria-invalid in the DOM: focus after a failed Continue or Submit looks for it. React: value and onChange(event). Vue: modelValue and onUpdate:modelValue, for v-model

In Vue, f is a reactive object: f.change works in the script and in the template. Do not destructure it.

Map the keys to the props of your design system. A component library renders the helper and error itself and links them with its own ids, so pass it input, which leaves out aria-describedby. MUI TextField puts unknown props on its root element, so the test id goes to the inner input through slotProps.htmlInput:

tsx
<TextField
  {...f.input}
  type={f.inputType}
  label={f.label}
  error={f.invalid}
  helperText={f.error ?? f.helperText}
  slotProps={{ htmlInput: { 'data-testid': f.testId } }}
/>

With Vuetify: <v-text-field v-bind="f.input" :type="f.inputType" :label="f.label" :error-messages="f.error" :hint="f.helperText" persistent-hint />.

@formhaus/core/fields has the helpers both wrappers use, for any framework:

HelperReturns
fieldState({ id, field, error, disabled, loading })The ids, texts, flags and aria above
fieldId({ id, field }), fieldAria({ id, field, error })Only the id, only the ARIA attributes
fieldValue(field, value), numberValue(text)The value core accepts for the field. numberValue follows the decimal rule of core: 0x10 stays text
inputType(field), optionChecked(value, option)The native input type, and whether an option is picked in a single or list value
fromDate(date, type), toDate(value)A date, datetime or time value from a Date, and a Date from such a value. See Value types
scaleView(field)The points, min, max and the start, middle and end anchors of a scale or rating. See Scale
optionText(options, value), optionValue(options, text)The text an autocomplete shows for a value, and the value for a typed text
toggleOption(value, option)A multiselect value with the option added or removed

Contract ​

The helpers above follow these rules. A component without them follows the rules itself.

  • The component renders its own label, helper text and error message. The renderer adds no wrapper around it.
  • Put the id prop on the focusable control. The error summary links to it, and focus after a failed Continue or Submit looks it up. See Ids and ARIA and Field focus.
  • Give the error message the id plus -error as its id, and set aria-invalid on the control while error is set.
  • Emit every change: onChange(value) in React, update:modelValue in Vue.
  • When field.autoAdvance is set, a discrete pick emits commit instead of a change. See autoAdvance.
  • Call onBlur and onFocus (React) or emit blur and focus (Vue). They report field_focused and field_blurred form events.
  • Disable the control while disabled or loading is set.
PropReactVue
Element ididid
Field from the definitionfieldfield
Current valuevaluemodelValue
Error messageerrorerror
Loading state from FormEngine.setFieldLoading() or a running optionsFrom providerloadingloading
Form is busy or disableddisableddisabled
Test idstestId, optionTestIdtestId, optionTestId
ChangeonChange(value)update:modelValue
Discrete pick with autoAdvanceonCommit(value), optionalcommit
Blur and focusonBlur(), onFocus()blur, focus
Matrix row contextmatrixmatrix
Revealed fields of the options, built-in and mapControls fields onlyrevealsreveals

autoAdvance ​

A field with "autoAdvance": true goes to the next step when the user picks an answer. A custom choice, scale or rating must then send the pick with f.commit(value). Without the helpers, call onCommit(value) in React or emit('commit', value) in Vue. onChange and update:modelValue only save the value, so with them the step never advances. commit saves the value too: do not also emit a change for that pick. Typing, arrow keys and other edits still emit a change.

vue
<script setup lang="ts">
import { fieldComponentEmits, fieldComponentProps, useFieldProps } from '@formhaus/vue';

const props = defineProps(fieldComponentProps);
const emit = defineEmits(fieldComponentEmits);
const f = useFieldProps(props, emit);
</script>

<template>
  <fieldset :id="f.id" data-fh="field" v-bind="f.aria">
    <legend>{{ f.label }}</legend>
    <button
      v-for="option in f.options"
      :key="option.value"
      type="button"
      :aria-pressed="f.value === option.value"
      :disabled="f.disabled"
      @click="f.commit(option.value)"
    >
      {{ option.label }}
    </button>
    <p v-if="f.error" :id="f.errorId">{{ f.error }}</p>
  </fieldset>
</template>
ts
const pick = (value: string) => {
  if (field.autoAdvance && onCommit) onCommit(value)
  else onChange(value)
}

ChoiceButtons in the React tab of Field helpers is the same field in React. Without the helpers in Vue, keep the two emit calls apart: emit(field.autoAdvance ? 'commit' : 'update:modelValue', value) fails vue-tsc, because each event has its own signature.

Ids and ARIA ​

The renderer always passes id. It is optional in the type only so that you can render the component on its own. fieldId then falls back to an id from the key. The built-in fields use these ids:

ElementId or attribute
Focusable control, or the group of a choice fieldid
Helper text`${id}-helper`
Error message`${id}-error`
Controlaria-describedby with the helper id and then the error id, only the ones shown
Controlaria-invalid="true" while error is set
Controlaria-required="true" when field.validation.required is set
Root of a choice fielda <fieldset> with a <legend>, or role="group" with aria-labelledby

Field focus ​

Focus finds the controls of a field by the id and by data-fh attributes:

  • The element with the id gets focus. When it is not an input, select, textarea, button or an element with tabindex, focus goes to the first of those inside it. Focus skips tabindex="-1" and aria-disabled="true".
  • Put data-fh="field" on the root of a field. For a radio, scale or rating, focus then goes to the checked option. Without it, and with the id on the first option, focus always lands on the first option.
  • When only the error message carries the id (id plus -error), data-fh="field" on the root lets focus find the control.
  • A file field whose native input is hidden with tabindex="-1" must put the id on the visible button, or mark the root with data-fh="field" so that focus finds the button.
  • In a custom group, mark the group root with data-fh="group", each item with data-fh-group-item and the Add button with data-fh-group-add. See Groups: Custom markup.

Value types ​

A component for a built-in type writes the value that core checks for that type. A value of another shape fails validation.

TypeValue
text, email, phone, password, textarea, hiddenstring
autocompletestring: the option value when the text matches an option label, else the text as typed. optionText and optionValue map between them
numberfinite number, or null when cleared
select, radiostring, the option value
multiselectstring[] of distinct option values. toggleOption adds or removes one
checkbox, switchboolean
datestring YYYY-MM-DD
datetimestring YYYY-MM-DDTHH:mm. Core also accepts seconds, milliseconds and a Z or +HH:mm offset, such as 2026-01-05T09:07:30.5+02:00
timestring HH:mm, 24-hour, or HH:mm:ss
scale, ratingnumber, the selected point
fileone File, or null
custom typestring, number or boolean

A file field holds one file. Use one file field per file.

Write null or '' when the user clears the control. An empty value is checked only against required and matchField.

For number, change and the built-in field write null when the input is empty. With change, text that is not a number stays a string, and validation fails with the number message rather than required. A browser number input reports an empty value for such text, so it becomes null. Never write NaN.

A composite control, such as a date picker and a time picker side by side for one datetime, writes one value. Write null until both parts are set: the field then fails only required. A partial string, such as 2026-01-05T, fails the format check and shows the datetime message. Pick one of the two and keep it.

Date pickers usually work with Date objects. fromDate(date, field.type) writes the value in local time, as YYYY-MM-DD, YYYY-MM-DDTHH:mm or HH:mm. toDate(value) reads every format core accepts:

ts
import { fromDate, toDate } from '@formhaus/core/fields'

fromDate(new Date(2026, 0, 5, 9, 7), 'datetime') // '2026-01-05T09:07'
toDate('2026-01-05T09:07') // 5 January 2026, 09:07 local time
toDate('2026-01-05T09:07Z') // the same instant in every time zone
toDate('09:07') // 1 January 1970, 09:07 local time
toDate('2026-02-31') // null
  • A value without Z or an offset is local time. A value with one is that instant.
  • An impossible date, such as 2026-02-31, gives null. Core accepts such a date as a format, so check it in the component when it matters.
  • A local time that does not exist, such as 02:30 on the night clocks move forward, moves forward like new Date(): toDate('2026-03-08T02:30') in New York is 03:30.

Scale ​

A component for scale or rating reads its range and anchor labels from the field:

  • The points run from validation.min to validation.max. They default to 1 and 5. A range has at most 11 points. rating always starts at 1.
  • The value is the point as a number, such as 7. A string such as "7" fails validation.
  • options are not the points. They are optional anchor labels for some points, and an option value is the point as a string, such as "0" or "10". The built-in scale shows the labels of the first point, the middle point floor((min + max) / 2) and the last point.

scaleView(field) from @formhaus/core/fields applies these rules:

ts
import { scaleView } from '@formhaus/core/fields'

const { points, min, max, anchors } = scaleView(field)
// points: [0, 1, ..., 10]
// anchors: [{ point: 0, label: 'Not likely' }, undefined, { point: 10, label: 'Very likely' }]

Pick a point with f.commit(point), as a number, so that autoAdvance works. See Scale and Rating.

Layout slots ​

layout replaces the parts of the form around the fields. A key you leave out keeps the built-in markup.

vue
<FormRenderer :definition="definition" :components="fields" :layout="{ section: MySection, errorSummary: MySummary }" @submit="onSubmit" />
tsx
<FormRenderer definition={definition} components={fields} layout={{ section: MySection, errorSummary: MySummary }} onSubmit={handleSubmit} />
KeyRendersReact propsVue props
fieldsThe container around the fieldsFieldsContainerPropsfieldsContainerProps
sectionA sectionSectionComponentPropssectionComponentProps
groupA repeatable groupGroupComponentPropsgroupComponentProps
groupItemOne item of a groupGroupItemComponentPropsgroupItemComponentProps
confirmDialogThe dialog before an item with answers is removedConfirmDialogComponentPropsconfirmDialogComponentProps
errorSummaryThe error summaryErrorSummaryComponentPropserrorSummaryComponentProps
formErrorsErrors that belong to no fieldFormErrorsComponentPropsformErrorsComponentProps
progressThe step progressFormStepProgressPropsformStepProgressProps
actionsThe buttonsFormActionsPropsformActionsProps

LayoutComponentMap types the whole map. Every slot gets the values the field components need from Value types: the slots only place the fields, they never change the values.

The <form class="fh-form"> element stays. Add a design-system class to it with className in React. In Vue, class and other attributes on FormRenderer or HeadlessFormRenderer fall through to the <form>.

vue
<FormRenderer class="usa-form" :definition="definition" @submit="onSubmit" />
tsx
<FormRenderer className="usa-form" definition={definition} onSubmit={handleSubmit} />

components.section, components.group, components.groupItem, components.confirmDialog, actionsComponent and progressComponent keep working until 2.0. layout wins when both are set. In development the renderer warns once per old key with the code legacy-layout.

Slot props ​

SlotPropsrootProps
fieldsrootProps. The fields come as children in React and as the default slot in Vueyes
sectionid, section, title, description, descriptionId, columns. The fields come as children in React and as the default slot in Vueno
groupThe group texts, items, canAdd, add, remove(id), requestRemove(id), addLabel, removeLabel, status, disabled, itemComponentno
groupItemid, index, title, titleId, canRemove, remove, requestRemove, hasContent, removeLabel, removeName, disabled. The fields come as children in React and as the fields component in Vueno
confirmDialogid, open, title, description, confirmLabel, cancelLabel, confirm(), cancel()no
errorSummaryrootProps, title, groups, count, showField(key)yes
formErrorsrootProps, errorsyes
progresscurrent, total, stepTitle, stepDescription, headingLevel, counterLabel, stepErrorCount, stepErrorsLabel, steps, marks, rootPropsyes
actionsThe props in Custom Actions & Progress, plus primaryDisabled and rootPropsyes
  • fields: without it the fields sit in a plain <div> with no spacing. A headless integration usually passes layout.fields with the stack or grid of its design system. The progress comes before the fields, and the error summary after them unless errorSummary is "top".
  • section: each child stays wrapped in the renderer component that keeps its value and errors current, so count fields with section.fields, not with the children. In a matrix section, columns holds the column options, and each row gets matrix. In any other section columns is undefined. A components.section gets no columns. See Matrix rows.
  • group: render each item with itemComponent.
  • groupItem: in Vue, render the fields with <component :is="fields" />.
  • confirmDialog: opens only for an item with answers. See Remove dialog.
  • errorSummary: title is the errorSummary label. Change it with labels: { errorSummary: 'There is a problem' } on the renderer. groups has one { id, title, entries } per step with errors, and each entry is { key, id, text, message } with the DOM id of the field. text is Label: message and message is the field message alone, the same text as the error under the field. showField opens the step of the field and focuses it. The summary renders when the errorSummary prop is not false and count is above 0. With errorSummary="top" it renders first in the form, before the progress and the fields. After a failed Continue or Submit, the summary takes focus only when it lists two or more errors. With one error, focus goes to the invalid field. See Error summary and step marks.
  • formErrors: errors holds the formErrors of a { ok: false } submit result and the message of a failed action. It renders when errors is not empty.
  • progress: renders only for a multi-step form. steps always holds every step of the active path as { id, title, errorCount, errorsLabel }. marks is true only when the renderer has stepErrorMarks, so draw the step list when marks is true. stepErrorsLabel and each errorsLabel are the stepErrors label, such as Errors: 2, and are undefined when the step has no errors.
  • actions: primaryDisabled is true while the disabled conditions of the primary action match, the next of the step or the form submit on the last step. Disable the primary button while primaryDisabled or loading is set. See Custom progress component.

Root props ​

rootProps carries the attributes that focus and screen readers depend on. Spread it on the root element: {...rootProps} in React, v-bind="rootProps" in Vue. A Vue component must declare rootProps, for example with the exported props object, or the object falls through as an attribute and focus does not find the root.

SlotrootProps
fields{ 'data-fh': 'fields' }
errorSummary{ 'data-fh': 'error-summary', role: 'alert', tabIndex: -1 }
formErrors{ 'data-fh': 'form-errors', role: 'alert' }
progress{ 'data-fh': 'progress' }
actions{ 'data-fh': 'actions' }

RootProps is the type. When the renderer has testIds, the rootProps of errorSummary also carry its data-testid.

Put rootProps on one element, and keep one role="alert". Some components type their own role prop, such as Carbon notifications, and reject the role of rootProps. Spread rootProps on a wrapper element then. Some design-system alerts put their attributes on an inner element or on a Transition, and some set their own role="alert". Then wrap the component in an element that takes rootProps, and turn off the role of the inner component if it has an option for that. With a pass-through option, such as pt: { root: rootProps } in PrimeVue, the component can take rootProps itself.

An error summary in React:

tsx
import type { ErrorSummaryComponentProps } from '@formhaus/react'

export function ErrorSummary({ rootProps, title, groups, showField }: ErrorSummaryComponentProps) {
  return (
    <div {...rootProps} className="my-summary">
      <strong>{title}</strong>
      <ul>
        {groups.flatMap((group) => group.entries).map((entry) => (
          <li key={entry.key}>
            <a
              href={`#${entry.id}`}
              onClick={(event) => {
                event.preventDefault()
                showField(entry.key)
              }}
            >
              {entry.text}
            </a>
          </li>
        ))}
      </ul>
    </div>
  )
}

A fields container in Vue:

vue
<script setup lang="ts">
import { fieldsContainerProps } from '@formhaus/vue'

defineProps(fieldsContainerProps)
</script>

<template>
  <div v-bind="rootProps" class="my-stack">
    <slot />
  </div>
</template>

Focus ​

Focus finds its targets by ids and data-fh attributes. A design system that spreads rootProps and puts the id prop on its controls gets the built-in focus:

  • After a failed Continue or Submit with one error, focus goes to the first invalid control inside [data-fh="fields"]. With several errors, it goes to [data-fh="error-summary"].
  • Without [data-fh="fields"], focus searches the whole form except the progress, actions and errorSummary roots.
  • showField(key) focuses the field as in Field focus.
  • Focus after Add and Remove depends on the ids and attributes in Groups: Custom markup.

The fh-form__fields, fh-field, fh-group and fh-error-summary classes work in place of the attributes until 2.0.

Remove dialog ​

The dialog opens only when the item has answers. Remove on an empty item, or on one that holds only its defaults, removes it at once. confirmRemove={false} in React or :confirm-remove="false" in Vue turns the dialog off.

A layout.confirmDialog stays mounted with its group, so it can animate. Show it while open is true. While it is closed, title and confirmLabel hold the remove label, cancelLabel the cancel label, description is '', and confirm and cancel do nothing. A components.confirmDialog mounts only while a removal waits for an answer, so open is always true there.

tsx
import type { ConfirmDialogComponentProps } from '@formhaus/react'

export function RemoveDialog({ id, open, title, description, confirmLabel, cancelLabel, confirm, cancel }: ConfirmDialogComponentProps) {
  return (
    <div id={id} role="alertdialog" aria-modal="true" aria-labelledby={`${id}-title`} aria-describedby={`${id}-description`} hidden={!open}>
      <h2 id={`${id}-title`}>{title}</h2>
      <p id={`${id}-description`}>{description}</p>
      <button type="button" onClick={cancel}>{cancelLabel}</button>
      <button type="button" onClick={confirm}>{confirmLabel}</button>
    </div>
  )
}

A group item in Vue:

vue
<script setup lang="ts">
import { groupItemComponentProps } from '@formhaus/vue'

const props = defineProps(groupItemComponentProps)
</script>

<template>
  <article role="group" :aria-labelledby="props.titleId" data-fh-group-item>
    <h4 :id="props.titleId" tabindex="-1">{{ props.title }}</h4>
    <component :is="props.fields" />
    <button
      v-if="props.canRemove"
      :id="`${props.titleId}-remove`"
      type="button"
      :aria-label="props.removeName"
      :disabled="props.disabled"
      @click="props.requestRemove"
    >
      {{ props.removeLabel }}
    </button>
  </article>
</template>

DefaultSection, DefaultGroup, DefaultGroupItem and DefaultConfirmDialog are exported for wrapping.

Matrix rows ​

A field in a layout: "matrix" section gets matrix: { columns, row }. columns are the options of the first row, and row is the index of the field among the visible fields of the section. Outside a matrix, matrix is undefined. In Vue, the field gets matrix only when it declares the prop, as fieldComponentProps does.

A custom section replaces the built-in matrix layout, so it draws the column headers from its columns prop, and each row field draws its own cells from matrix.columns. The value of a row is the option value, as for any radio or multiselect.

tsx
import { fieldProps, type FieldComponentProps, type SectionComponentProps } from '@formhaus/react';

export function MatrixSection({ id, title, description, descriptionId, columns, children }: SectionComponentProps) {
  return (
    <fieldset id={id} aria-describedby={descriptionId} className={columns ? 'matrix' : undefined}>
      <legend>{title}</legend>
      {description && <p id={descriptionId}>{description}</p>}
      {columns && (
        <div className="matrix__header" aria-hidden="true">
          {columns.map((column) => (
            <span key={column.value}>{column.label}</span>
          ))}
        </div>
      )}
      {children}
    </fieldset>
  );
}

export function RadioRow(props: FieldComponentProps) {
  const f = fieldProps(props);
  const { matrix } = props;
  return (
    <fieldset data-fh="field" data-row={matrix?.row} className={matrix ? 'matrix__row' : undefined} {...f.aria}>
      <legend>{f.label}</legend>
      {f.options.map((option, index) => (
        <label key={option.value}>
          <input type="radio" {...f.option(option, index)} onChange={() => f.change(option.value)} />
          <span className={matrix ? 'visually-hidden' : undefined}>{option.label}</span>
        </label>
      ))}
      {f.error && <p id={f.errorId}>{f.error}</p>}
    </fieldset>
  );
}
vue
<script setup lang="ts">
import { sectionComponentProps } from '@formhaus/vue';

const props = defineProps(sectionComponentProps);
</script>

<template>
  <fieldset :id="props.id" :aria-describedby="props.descriptionId" :class="{ matrix: props.columns }">
    <legend>{{ props.title }}</legend>
    <p v-if="props.description" :id="props.descriptionId">{{ props.description }}</p>
    <div v-if="props.columns" class="matrix__header" aria-hidden="true">
      <span v-for="column in props.columns" :key="column.value">{{ column.label }}</span>
    </div>
    <slot />
  </fieldset>
</template>
vue
<script setup lang="ts">
import { fieldComponentEmits, fieldComponentProps, useFieldProps } from '@formhaus/vue';

const props = defineProps(fieldComponentProps);
const emit = defineEmits(fieldComponentEmits);
const f = useFieldProps(props, emit);
</script>

<template>
  <fieldset data-fh="field" :data-row="props.matrix?.row" :class="{ matrix__row: props.matrix }" v-bind="f.aria">
    <legend>{{ f.label }}</legend>
    <label v-for="(option, index) in f.options" :key="option.value">
      <input type="radio" v-bind="f.option(option, index)" @change="f.change(option.value)" />
      <span :class="{ 'visually-hidden': props.matrix }">{{ option.label }}</span>
    </label>
    <p v-if="f.error" :id="f.errorId">{{ f.error }}</p>
  </fieldset>
</template>

Map the row as the field type and the section as a slot:

vue
<FormRenderer :definition="definition" :components="{ radio: RadioRow }" :layout="{ section: MatrixSection }" @submit="onSubmit" />
tsx
<FormRenderer definition={definition} components={{ radio: RadioRow }} layout={{ section: MatrixSection }} onSubmit={handleSubmit} />

Fully custom layout ​

Build the whole form on useFormEngine and read the error summary with errorSummary from @formhaus/core.

tsx
import { errorSummary, type FormDefinition, type SubmitHandler } from '@formhaus/core';
import { useFormEngine } from '@formhaus/react';

type Props = { definition: FormDefinition; send: SubmitHandler };

export function CustomLayoutForm({ definition, send }: Props) {
  const engine = useFormEngine(definition);
  const { groups, topLevelErrors, count } = errorSummary(engine, { idPrefix: 'signup' });
  const showField = (key: string, id: string) => {
    engine.goToStepWithField(key);
    setTimeout(() => document.getElementById(id)?.focus());
  };
  return (
    <form
      onSubmit={(event) => {
        event.preventDefault();
        engine.submit(send);
      }}
    >
      {engine.visibleFields.map((field) => (
        <label key={field.key}>
          {field.label}
          <input
            id={`signup-${field.key}`}
            aria-invalid={!!engine.errors[field.key]}
            value={String(engine.values[field.key] ?? '')}
            onChange={(event) => engine.setValue(field.key, event.target.value)}
          />
        </label>
      ))}
      {topLevelErrors.map((error) => (
        <p key={error} role="alert">
          {error}
        </p>
      ))}
      {count > 0 && (
        <nav aria-label={`${count} errors`}>
          {groups.map((group) => (
            <ul key={group.id} aria-label={group.title}>
              {group.entries.map((entry) => (
                <li key={entry.key}>
                  <a
                    href={`#${entry.id}`}
                    onClick={(event) => {
                      event.preventDefault();
                      showField(entry.key, entry.id);
                    }}
                  >
                    {entry.text}
                  </a>
                </li>
              ))}
            </ul>
          ))}
        </nav>
      )}
      {engine.isLastStep ? (
        <button type="submit">Send</button>
      ) : (
        <button type="button" onClick={() => engine.next()}>
          Next
        </button>
      )}
    </form>
  );
}
vue
<script setup lang="ts">
import { errorSummary, type FormDefinition, type SubmitHandler } from '@formhaus/core';
import { useFormEngine } from '@formhaus/vue';
import { computed, nextTick } from 'vue';

const props = defineProps<{ definition: FormDefinition; send: SubmitHandler }>();
const { engine, visibleFields, values, errors, topLevelErrors, isLastStep } = useFormEngine(() => props.definition);
const summary = computed(() => {
  void [values.value, errors.value, topLevelErrors.value];
  return errorSummary(engine, { idPrefix: 'signup' });
});

function input(key: string, event: Event) {
  engine.setValue(key, (event.target as HTMLInputElement).value);
}

async function showField(key: string, id: string) {
  engine.goToStepWithField(key);
  await nextTick();
  document.getElementById(id)?.focus();
}
</script>

<template>
  <form @submit.prevent="engine.submit(props.send)">
    <label v-for="field in visibleFields" :key="field.key">
      {{ field.label }}
      <input :id="`signup-${field.key}`" :aria-invalid="!!errors[field.key]" :value="values[field.key] ?? ''" @input="input(field.key, $event)" />
    </label>
    <p v-for="error in summary.topLevelErrors" :key="error" role="alert">{{ error }}</p>
    <nav v-if="summary.count > 0" :aria-label="`${summary.count} errors`">
      <ul v-for="group in summary.groups" :key="group.id" :aria-label="group.title">
        <li v-for="entry in group.entries" :key="entry.key">
          <a :href="`#${entry.id}`" @click.prevent="showField(entry.key, entry.id)">{{ entry.text }}</a>
        </li>
      </ul>
    </nav>
    <button v-if="isLastStep" type="submit">Send</button>
    <button v-else type="button" @click="engine.next()">Next</button>
  </form>
</template>

errorSummary(engine, { idPrefix?, labels? }) returns { groups, topLevelErrors, count }. It reads the engine errors only: groups and count are the ones the errorSummary slot gets, and topLevelErrors is engine.topLevelErrors, without the message of a failed onSubmit. Each entry id is the DOM id the Formhaus renderers give the control of that field, with idPrefix as the form id (fh without one). For a top-level key made of letters, digits, _ and - that is <idPrefix>-<key>, as in the snippet, except that a run of dashes before reveal becomes one dash; group items and other keys get derived ids. labels overrides the definition labels for step and item titles.

Next steps ​