Form kits
A form kit is the rendering contract between Form, Please and your project's UI. Create one kit for the controls, structure, and grid that forms in the project share.
| Part | Responsibility | Example |
|---|---|---|
| Control | Connect one typed value to one interactive component. | An email input, checkbox, or date picker. |
| Slot | Render structure around controls and groups. | A field wrapper, section grid, or submit button. |
Choose a starting point
Do not build a custom kit when a shipped preset already matches the project. Choose the smallest starting point that covers the UI:
| Project need | Start with | What you own |
|---|---|---|
| Semantic HTML with project CSS | nativeFormKit from form-please/preset-native | Styles only. |
| Native controls with some project components | createNativeControls() and createDefaultSlots() | Only the controls or slots that you replace. |
| A project design system | defineControl() and FormKitSlots | The interactive components and form structure. |
| Material UI 9 | createMuiFormKit() from form-please/preset-mui | The Material UI theme and field definitions. |
The following walkthrough uses the second path. It keeps the accessible default structure, adds one project control, and produces a kit that can render a form.
Build a project kit
1. Define one project control
Use defineControl<Value, OwnProps, Context, Option>() to define the control contract.
Value must match the schema input type for each field that uses the control.
OwnProps describes application-owned props for one field. Context supplies shared runtime data. Set
Option only when the control displays a selectable collection.
"use client"
import { type , } from "form-please"
export type = {
readonly ?: string
}
export function ({
,
,
,
,
,
: ,
,
,
,
}: <string | undefined, >) {
return (
<
={["aria-describedby"]}
={. || }
={}
={.}
={.}
={}
={() =>
(...() || )
}
={.}
={}
={.}
={}
={ ?? ""}
/>
)
}
export const = <string | undefined, >({
: ,
})The example uses undefined for an empty value. This is a control decision.
Choose an empty value that matches the schema input type.
2. Assemble and export the kit
Start with the shipped controls and slots. Add project controls to the registry
before calling createFormKit:
const controls = {
...createNativeControls(),
uppercase,
}
export const projectKit = createFormKit({
controls,
slots: createDefaultSlots(),
})Create the kit once at module scope and import that instance wherever forms are
defined or rendered. createFormKit freezes snapshots of the registries and
grid; a finished kit does not have a runtime extension API.
3. Define fields with the kit
The control registry keys become the allowed control values. TypeScript checks
the schema path, control value type, props, and options together:
const projectDefinition = projectKit.defineForm(profileSchema, {
ui: [
{
kind: "field",
path: "displayName",
control: "uppercase",
label: "Display name",
props: { placeholder: "ADA LOVELACE" },
},
{
kind: "field",
path: "age",
control: "number",
label: "Age",
props: { min: 18, max: 120, step: 1 },
},
{
kind: "field",
path: "role",
control: "select",
label: "Role",
options: [
{ value: "admin", label: "Administrator" },
{ value: "member", label: "Member" },
],
props: {
emptyOption: { label: "Select a role" },
},
},
{
kind: "field",
path: "active",
control: "checkbox",
label: "Active account",
},
],
})This definition uses the project uppercase control together with the shipped
number, select, and checkbox controls.
4. Bind and render a form
Use the same kit to create the form binding and render its generated UI:
export function ProjectProfileForm() {
const form = projectKit.useForm(projectDefinition, {
defaultValues: {
displayName: "",
age: undefined,
role: undefined,
active: false,
members: [],
},
onSubmit({ value }) {
console.log(value)
},
})
return (
<projectKit.AutoForm form={form}>
<projectKit.Submit>Save profile</projectKit.Submit>
</projectKit.AutoForm>
)
}projectKit.AutoForm renders the form and generated fields. projectKit.Submit
uses the kit's submit slot. After validation succeeds, onSubmit receives the
schema output in value.
All JavaScript package entries are React client modules. In a Next.js App Router
project, keep the kit and the component that calls useForm behind a
"use client" boundary.
Check the kit before adopting it
- Put the shared kit in one project module and import the same instance in forms.
- Confirm that TypeScript rejects a control whose value type does not match its schema path.
- Submit an invalid form and confirm that the first invalid control receives focus.
- Confirm that labels, descriptions, and visible errors are announced by a screen reader.
- Test disabled and readonly fields, plus add, move, and remove actions when the kit supports arrays.
The project kit is now usable. The remaining sections document the complete control and slot contracts for replacing more of the baseline.
Controls
A control receives a field value from React Hook Form. It renders the interactive
component and sends typed values back to the form. It does not render the field
label, description, or errors; the Field and ErrorMessage slots render that
content.
Control props
ControlProps<Value, OwnProps, Context, Option> contains these properties:
| Property | Purpose |
|---|---|
path | The field path in the schema input. |
value | The current React Hook Form value. |
setValue(value) | Store a new typed value. Do not pass a browser event. |
blur() | Mark the field as blurred. Call it from the component blur event. |
input | IDs, the field name, and the focus ref for the interactive element. |
meta | Validation and interaction state for the field. |
props | The readonly application-owned props from the definition. |
options | The current readonly selectable collection, for option-capable controls. |
context | The deeply readonly context from kit.useForm. |
disabled | Prevent all interaction when this value is true. |
readOnly | Show the value but prevent changes when this value is true. |
required | Expose the resolved UI requirement to the component. |
Connect every applicable input property to the interactive element:
| Input property | Required connection |
|---|---|
input.id | Set the element id. The field label uses this ID. |
input.name | Set the element name. This also supports autofill and browser diagnostics. |
input.ref | Attach it to the focusable element. Invalid submission uses this ref for focus. |
input["aria-describedby"] | Set aria-describedby. It connects descriptions and visible errors. |
Set aria-invalid from meta.invalid. The value becomes true only when the
form displays field errors.
The remaining metadata has these meanings:
| Metadata | Meaning |
|---|---|
dirty | The user modified the field value. |
touched | The user interacted with the field. |
validating | React Hook Form is validating the field. |
errors | All normalized field issues. |
displayErrors | Issues that the form currently shows. |
invalid | displayErrors contains at least one issue. |
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.
Option controls
Mark the value written by a reusable option item with OptionValue<Value>.
Declare that item as the fourth defineControl type argument. The marker has
no runtime representation.
type RoleOption = {
readonly id: OptionValue<string>
readonly label: string
}
function RoleChoiceControl({
value,
setValue,
blur,
input,
meta,
options,
disabled,
readOnly,
required,
}: ControlProps<string | undefined, unknown, unknown, RoleOption>) {
return (
<select
aria-describedby={input["aria-describedby"]}
aria-invalid={meta.invalid || undefined}
aria-readonly={readOnly || undefined}
disabled={disabled}
id={input.id}
name={input.name}
onBlur={blur}
onChange={(event) => {
if (!readOnly) setValue(event.currentTarget.value || undefined)
}}
ref={input.ref}
required={required}
value={value ?? ""}
>
<option value="">Choose a role</option>
{options.map((item) => (
<option key={item.id} value={item.id}>
{item.label}
</option>
))}
</select>
)
}
const roleChoice = defineControl<
string | undefined,
unknown,
unknown,
RoleOption
>({
component: RoleChoiceControl,
})The control keeps the broad string contract. When a form binds it to a schema
path such as "admin" | "member", the field definition accepts only those IDs.
For an array field, it uses the array element union. Nested option objects are
not searched for choice collections.
Use as const or satisfies when options are declared outside the field node.
Otherwise, TypeScript can widen a literal ID such as "admin" to string.
Read Option values for definition examples.
Shipped native controls
createNativeControls() returns a fresh, frozen registry.
| Control | Value type | Props |
|---|---|---|
text | string | undefined | type, placeholder, autoComplete |
textarea | string | undefined | placeholder, autoComplete, rows |
number | number | undefined | min, max, step, placeholder |
date | string | undefined | min, max |
time | string | undefined | min, max, step |
select | string | undefined | Optional emptyOption; items come from field options. |
checkbox | boolean | No props. |
file | File | undefined | accept |
The select control requires field options. Add props.emptyOption when the
current value can be undefined. Do not combine emptyOption with an item
whose value is an empty string.
Import option types from form-please/native-controls. Use nativeFormKit
from form-please/preset-native when you need the complete native baseline.
Material UI controls
createMuiFormKit() supplies these controls:
| Controls | Value type | Props type |
|---|---|---|
text, textarea, password, email, url, tel, search | string | undefined | MuiTextFieldProps |
date, time, datetime-local | string | undefined | MuiTextFieldProps |
number | number | undefined | MuiTextFieldProps |
select | string | undefined | MuiSelectProps |
select-multiple | readonly string[] | MuiSelectMultipleProps |
radio | string | undefined | MuiRadioProps |
autocomplete | string | undefined | MuiAutocompleteProps |
autocomplete-multiple | readonly string[] | MuiAutocompleteMultipleProps |
checkbox | boolean | MuiCheckboxProps |
switch | boolean | MuiSwitchProps |
file | File | undefined | MuiFileProps |
files | readonly File[] | MuiFileProps |
slider | number | MuiSliderProps |
range-slider | readonly number[] | MuiRangeSliderProps |
Import these props types from form-please/preset-mui. The props types omit
props that the kit owns, such as the current value, field name, disabled state,
and input ref.
The field options accepted by select, radio, and autocomplete controls narrow
to the selected schema path. This rule also applies to their multiple-value
variants. Custom select children and autocomplete freeSolo values are
application-owned and do not participate in option-value inference.
Value and submission rules
Controls store schema input values in React Hook Form. They do not serialize
values for submission. A field name or a browser FormData value does not
change the submitted result.
After validation succeeds, onSubmit receives the schema output, schema input,
form binding, and submitter snapshot. Read Validation and
submission for the complete sequence. Read Product
workflows for named submit actions.
Slots
Slots own the form structure. A complete FormKitSlots registry contains six
React components.
| Slot | Renders | Important props |
|---|---|---|
Field | A label, description, control, and field errors. | rootProps, labelProps, descriptionProps, control, errors |
Section | A section heading and child layout. | rootProps, layoutProps, title, description, children |
Array | An array label, errors, items, and add action. | canAdd, add, children, errors |
ArrayItem | One array item and its move or remove actions. | index, canMoveUp, canMoveDown, move, remove |
ErrorMessage | One normalized form issue. | rootProps, issue |
Submit | The kit submit button. | buttonProps, values, isSubmitting |
Field, Section, and Array also receive typed slotOptions. The form
definition supplies these options for one node.
Slot prop reference
The slot types contain these props:
| Props | Slots | Purpose |
|---|---|---|
rootProps | Field, Section, Array, ArrayItem, ErrorMessage | Attributes and refs for the slot root. |
slotOptions | Field, Section, Array | Readonly options from the current definition node. |
label, labelProps | Field, Array | Optional label content and its generated attributes. |
description, descriptionProps | Field, Array | Optional description content and its generated attributes. |
control | Field | The rendered control component. |
errors | Field, Array | Rendered ErrorMessage slot elements for visible issues. |
disabled, readOnly, required | Field | Resolved interaction requirements for the field. |
title, description | Section | Optional section content. |
layoutProps | Section | Grid attributes for the child layout element. |
invalid | Array | Whether the array has visible issues. |
canAdd, add() | Array | Add-action state and callback. |
index | ArrayItem | The zero-based item index. |
disabled, readOnly | ArrayItem | Resolved interaction state for the item. |
canMoveUp, canMoveDown | ArrayItem | Boundary and interaction state for move actions. |
move(toIndex), remove() | ArrayItem | Array mutation callbacks. |
children | Section, Array, ArrayItem | Rendered child nodes or items. |
issue | ErrorMessage | One normalized FormIssue. |
buttonProps | Submit | Button content, attributes, submit type, and resolved disabled state. |
values | Submit | The current form input object. |
isSubmitting | Submit | Whether the submit handler has a pending promise. |
Implement a field slot
Spread generated props on the matching elements. These props preserve labels, descriptions, focus behavior, CSS hooks, and resolved state.
function CardFieldSlot({
rootProps,
label,
labelProps,
description,
descriptionProps,
slotOptions,
control,
errors,
required,
}: FieldSlotProps<CardFieldOptions>) {
let requiredMark: ReactNode = null
if (required) {
requiredMark = <span aria-hidden="true"> *</span>
}
let labelNode: ReactNode = null
if (label !== undefined) {
labelNode = (
<label {...labelProps} htmlFor={labelProps.htmlFor}>
{label}
{requiredMark}
</label>
)
}
let descriptionNode: ReactNode = null
if (description !== undefined) {
descriptionNode = <p {...descriptionProps}>{description}</p>
}
return (
<div {...rootProps} data-tone={slotOptions?.tone ?? "default"}>
{labelNode}
{descriptionNode}
{control}
{errors}
</div>
)
}Render control exactly once. Render each item in errors so users can read
the displayed validation issues.
Implement an array slot
Call add() from a button with type="button". Disable the action when
canAdd is false. The kit sets canAdd to false for disabled and readonly
arrays.
function ListArraySlot({
rootProps,
label,
labelProps,
description,
descriptionProps,
slotOptions,
errors,
canAdd,
add,
children,
}: ArraySlotProps<ListSlotOptions>) {
let labelId: string | undefined
let labelNode: ReactNode = null
if (label !== undefined) {
labelId = labelProps.id
labelNode = <h2 {...labelProps}>{label}</h2>
}
let descriptionId: string | undefined
let descriptionNode: ReactNode = null
if (description !== undefined) {
descriptionId = descriptionProps.id
descriptionNode = <p {...descriptionProps}>{description}</p>
}
return (
<section
{...rootProps}
aria-describedby={descriptionId}
aria-labelledby={labelId}
>
{labelNode}
{descriptionNode}
{errors}
{children}
<button disabled={!canAdd} type="button" onClick={add}>
{slotOptions?.addLabel ?? "Add item"}
</button>
</section>
)
}The ArrayItem slot owns move and remove controls. Use canMoveUp and
canMoveDown for move state. Also disable all item actions when disabled or
readOnly is true.
Implement a submit slot
Spread buttonProps on the button. These props contain type="submit" and the
resolved disabled state.
function SaveSubmitSlot({ buttonProps, isSubmitting }: SubmitSlotProps) {
const { children, ...props } = buttonProps
let content = children
if (isSubmitting) {
content = "Saving…"
}
return <button {...props}>{content}</button>
}Use isSubmitting for pending content. Use values only when the button UI
depends on the current form input.
Build the slot registry
createFormKit requires all six slots. Start with createDefaultSlots() when
you only need to replace some slots.
const defaultSlots = createDefaultSlots()
const brandedSlots = {
...defaultSlots,
Field: CardFieldSlot,
Array: ListArraySlot,
Submit: SaveSubmitSlot,
} satisfies FormKitSlots<CardFieldOptions, never, ListSlotOptions>
const brandedKit = createFormKit({
controls,
slots: brandedSlots,
})The generic parameters of FormKitSlots define options for Field, Section,
and Array, in that order. Use never when a slot does not accept options.
Pass the typed options through slotOptions in the form definition:
const brandedDefinition = brandedKit.defineForm(profileSchema, {
ui: [
{
kind: "field",
path: "displayName",
control: "uppercase",
label: "Display name",
slotOptions: { tone: "emphasis" },
},
{
kind: "array",
path: "members",
label: "Team members",
itemDefault: { name: "" },
slotOptions: { addLabel: "Add team member" },
children: [
{
kind: "field",
path: "name",
control: "text",
label: "Name",
},
],
},
],
})Generated structural props
rootProps contains stable attributes for styling and state inspection.
Always spread it on the slot root element.
| Attribute | Meaning |
|---|---|
data-fp-node | The structural node type. |
data-fp-path | The current field or array path, when applicable. |
data-fp-span | The resolved grid span, when applicable. |
data-invalid, data-dirty, data-touched | Current validation and interaction state. |
data-disabled, data-readonly, data-required | Current interaction requirements. |
data-validating | The node is validating. |
Section.layoutProps contains data-fp-layout="grid" and
data-fp-columns. Spread it on the element that arranges the section children.
Import form-please/layout.css for optional responsive structure. Read
Styling for the CSS contract.
Shipped slot sets
Use createDefaultSlots() for accessible HTML structure without a visual
theme. Its i18n option changes array action labels.
Use createMuiFormKit() for Material UI 9 controls, slots, and a 12-column
grid. The application still owns the Material UI theme.
Read the API reference for factory imports and localization types.