Skip to content
Form, Please

Accessibility

Generated fields connect labels, descriptions, errors, and focus to each control. The shipped controls and slots use these connections. A custom control or slot must use them too.

What the library does

BehaviorHow it worksSource
Focus on the first invalid fieldAfter an invalid submit, React Hook Form (RHF) focuses the first invalid field through input.ref. Read Focus the first issue.src/create-form-kit.tsx
Label associationThe field slot gets labelProps.htmlFor, which is equal to input.id.src/create-form-kit.tsx
Error and description associationinput["aria-describedby"] contains the description ID and the ID of each visible error.src/create-form-kit.tsx
Invalid statemeta.invalid is true only when the form shows field errors. The shipped controls set aria-invalid from it.src/create-form-kit.tsx, src/native-controls/native-controls.tsx, src/preset-mui/controls.tsx
Required stateThe control gets the resolved required value. The shipped controls set the native required attribute. required does not add a validation rule. Read required is UI-only.src/create-form-kit.tsx, src/native-controls/native-controls.tsx, src/preset-mui/controls.tsx
Error announcementThe shipped ErrorMessage slots render each issue with role="alert".src/default-slots/default-slots.tsx, src/preset-mui/slots.tsx
Array action labelsThe default array item buttons have an aria-label from the kit i18n object. Read Localization.src/default-slots/default-slots.tsx

Connect a custom control

A custom control must attach the supplied properties to its interactive element. The following control shows the complete contract:

type CurrencyProps = {
	readonly currency: string
}
 
function CurrencyControl({
	value,
	setValue,
	blur,
	input,
	meta,
	props: currencyProps,
	disabled,
	readOnly,
	required,
}: ControlProps<number | undefined, CurrencyProps>) {
	return (
		<div>
			<span aria-hidden="true">{currencyProps.currency}</span>
			<input
				aria-describedby={input["aria-describedby"]}
				aria-invalid={meta.invalid || undefined}
				disabled={disabled}
				id={input.id}
				name={input.name}
				onBlur={blur}
				onChange={(event) => {
					if (event.currentTarget.value === "") {
						setValue(undefined)
						return
					}
					setValue(event.currentTarget.valueAsNumber)
				}}
				readOnly={readOnly}
				ref={input.ref}
				required={required}
				type="number"
				value={value ?? ""}
			/>
		</div>
	)
}
 
const currency = defineControl<number | undefined, CurrencyProps>({
	component: CurrencyControl,
})
 
const billingKit = createFormKit({
	controls: { ...createNativeControls(), currency },
	slots: createDefaultSlots(),
})

Connect every applicable input property to the interactive element:

Input propertyRequired connection
input.idSet the element id. The field label uses this ID.
input.nameSet the element name. This also supports autofill and browser diagnostics.
input.refAttach it to the focusable element. Invalid submission uses this ref for focus.
input["aria-describedby"]Set aria-describedby. It connects descriptions and visible errors.
meta.invalidSet aria-invalid from it.

A custom control must also:

  1. Call setValue after a valid control change.
  2. Call blur from its blur event.
  3. Honor disabled, readOnly, and required.

Native text inputs support a readOnly property. Some components do not. A custom select, checkbox, or file control must prevent changes when readOnly is true.

Read Control props for the other properties.

Keep custom slots accessible

Custom slots must preserve semantic groups, labels, issue output, and usable buttons. Spread labelProps, descriptionProps, and rootProps on the matching elements. labelProps connects the label to the control. descriptionProps and the ErrorMessage rootProps contain the IDs that aria-describedby refers to. Read Slot prop reference.

Test the accessibility behavior

Test keyboard input, focus, invalid state, and read-only behavior for each reusable custom control. Find controls by their labels and roles. Read Test the public experience.

Next steps