Customization
Formwright renders native HTML inputs by default, but it's designed to work with any component library. Choose an approach based on whether you want automatic layout (schema-driven via FormRuntimeRoot) or manual field composition (FormField compound).
Decision guide
| I want to... | Use |
|---|---|
| Compose individual fields with my own inputs | FormField compound parts |
| Compose array fields manually | FormArray compound parts |
| Restyle labels, errors, or wrappers globally (auto layout) | fieldSlots on FormRuntimeRoot |
| Replace one input type globally (auto layout) | fieldRendererMap on FormRuntimeRoot |
| Build a reusable custom renderer component | FieldComposer + fieldRendererMap |
| Restyle array field shells and buttons (auto layout) | arraySlots on FormRuntimeRoot |
| Replace a layout container (grid, section) | layoutRendererMap on FormRuntimeRoot |
FormField compound parts
Use FormField when you want to compose individual fields yourself — without FormRuntimeRoot. You still need FormRuntimeProvider as an ancestor; FormRuntimeRoot is optional.
FormField.Control accepts a render function. It receives all field props: value, onChange, onBlur, error, state, loading, options. Wire them to any input component.
import { FormField } from "formwright/react";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
function ProfileFields() {
return (
<div className="space-y-4">
{/* Text input */}
<FormField.Root path="name">
<FormField.Label />
<FormField.Control>
{({ value, onChange, onBlur, error, state }) => (
<Input
value={typeof value === "string" ? value : ""}
onChange={(e) => onChange(e.target.value)}
onBlur={onBlur}
disabled={state.disabled}
aria-invalid={Boolean(error)}
/>
)}
</FormField.Control>
<FormField.Error />
<FormField.Help />
</FormField.Root>
{/* Select with datasource options */}
<FormField.Root path="role">
<FormField.Label />
<FormField.Control>
{({ value, onChange, options }) => (
<Select value={typeof value === "string" ? value : ""} onValueChange={onChange}>
<SelectTrigger><SelectValue placeholder="Choose role..." /></SelectTrigger>
<SelectContent>
{(options ?? []).map((opt) => (
<SelectItem key={String(opt.value)} value={String(opt.value)}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</FormField.Control>
<FormField.Error />
</FormField.Root>
{/* Default HTML input — no render function needed */}
<FormField.Root path="email">
<FormField.Label />
<FormField.Control />
<FormField.Error />
</FormField.Root>
</div>
);
}
FormField parts
| Part | Description |
|---|---|
FormField.Root | Required wrapper. Takes path prop. Provides field context to all descendant parts. |
FormField.Label | Renders the field label. Respects accessibility.labelHidden. |
FormField.Description | Renders description from the field schema. |
FormField.Control | Without children: renders default HTML input. With render function: passes FormFieldControlRenderProps. |
FormField.Error | Renders the validation error message. |
FormField.Help | Renders helpText from the field schema. |
FormFieldControlRenderProps
{
field: ResolvedFieldModel;
state: DerivedFieldState; // visible, disabled, readonly, required
value: unknown;
error?: string;
onChange: (value: unknown) => void;
onBlur?: () => void;
loading?: boolean; // datasource loading
options?: SelectOption[]; // datasource options
}
FormArray compound parts
Use FormArray to manually compose array fields. FormArray.Items takes a render function called for each item.
import { FormArray, FormField } from "formwright/react";
import { Input } from "@/components/ui/input";
function LineItemsField() {
return (
<FormArray.Root path="lineItems">
<FormArray.Header />
<FormArray.Items>
{(item, index) => (
<FormArray.Item key={item.id} index={index}>
<div className="grid grid-cols-3 gap-2">
<FormField.Root path={`lineItems.${index}.name`}>
<FormField.Control>
{({ value, onChange }) => (
<Input value={String(value ?? "")} onChange={(e) => onChange(e.target.value)} />
)}
</FormField.Control>
<FormField.Error />
</FormField.Root>
</div>
<FormArray.Remove index={index}>Remove</FormArray.Remove>
</FormArray.Item>
)}
</FormArray.Items>
<FormArray.Add>Add item</FormArray.Add>
</FormArray.Root>
);
}
FormArray parts
| Part | Props | Description |
|---|---|---|
FormArray.Root | path | Required wrapper. Provides array context. |
FormArray.Header | — | Renders label and description. |
FormArray.Items | children: (item, index) => ReactNode | Iterates over items, calls render function for each. |
FormArray.Item | index | Item shell wrapper. |
FormArray.Add | children? | Add-item button. Disabled when array is disabled/readonly. |
FormArray.Remove | index, children? | Remove-item button for a given index. |
Option 1: Slots
Slots replace individual pieces of the field composition shell. Use this for global theming — when you want the structure to stay the same but the styles to change.
Available field slots: Shell, Label, Description, Control, Error, Help
import { FormRuntimeRoot } from "formwright/react";
<FormRuntimeRoot
rootLayoutId="root"
fieldSlots={{
Label: ({ label, state }) => (
<label className={`text-sm font-medium ${state.required ? "after:content-['*']" : ""}`}>
{label}
</label>
),
Error: ({ error }) =>
error ? <p className="text-sm text-red-600 mt-1">{error}</p> : null,
Help: ({ helpText }) =>
helpText ? <p className="text-xs text-gray-500">{helpText}</p> : null,
}}
/>
The Control slot wraps the entire input area. Use it to add wrappers or global input styles without replacing the input itself:
fieldSlots={{
Control: ({ defaultControl, error }) => (
<div className={`relative ${error ? "ring-1 ring-red-500 rounded" : ""}`}>
{defaultControl}
</div>
),
}}
Option 2: Renderer map
Replace an entire field type with your own component. Use this when you want shadcn/ui, Radix, or any other component instead of the default input.
import { FormRuntimeRoot, type FieldRendererComponent } from "formwright/react";
import { Input } from "@/components/ui/input";
const ShadcnInput: FieldRendererComponent = ({ value, onChange, onBlur, error, state }) => (
<div className="space-y-1">
<Input
value={typeof value === "string" ? value : ""}
onChange={(e) => onChange(e.target.value)}
onBlur={onBlur}
disabled={state.disabled}
aria-invalid={Boolean(error)}
/>
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
);
// Apply to all text and email fields:
<FormRuntimeRoot
rootLayoutId="root"
fieldRendererMap={{
text: ShadcnInput,
email: ShadcnInput,
password: ShadcnInput,
}}
/>
Select with options
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import type { FieldRendererComponent } from "formwright/react";
const ShadcnSelect: FieldRendererComponent = ({ value, onChange, options, error, state }) => (
<div className="space-y-1">
<Select
value={typeof value === "string" ? value : ""}
onValueChange={onChange}
disabled={state.disabled}
>
<SelectTrigger aria-invalid={Boolean(error)}>
<SelectValue placeholder="Choose..." />
</SelectTrigger>
<SelectContent>
{(options ?? []).map((opt) => (
<SelectItem key={String(opt.value)} value={String(opt.value)}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
);
Boolean (switch)
import { Switch } from "@/components/ui/switch";
import type { FieldRendererComponent } from "formwright/react";
const BooleanSwitch: FieldRendererComponent = ({ value, onChange, state }) => (
<Switch
checked={Boolean(value)}
onCheckedChange={onChange}
disabled={state.disabled}
/>
);
Date picker
import type { FieldRendererComponent } from "formwright/react";
const DatePickerField: FieldRendererComponent = ({ value, onChange, error }) => {
const date = typeof value === "string" && value ? new Date(value) : undefined;
return (
<div className="space-y-1">
<MyDatePicker
selected={date}
onSelect={(d) => onChange(d ? d.toISOString().slice(0, 10) : "")}
/>
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
);
};
Option 3: Mix slots and renderer map
Use slots for global structure (label style, error style) and fieldRendererMap only for fields that need a custom component:
<FormRuntimeRoot
rootLayoutId="root"
fieldSlots={{
Label: ({ label }) => <label className="text-sm font-medium">{label}</label>,
Error: ({ error }) => error ? <p className="text-sm text-destructive">{error}</p> : null,
}}
fieldRendererMap={{
select: ShadcnSelect,
date: DatePickerField,
boolean: BooleanSwitch,
}}
/>
FieldRendererComponent props
Every component registered in fieldRendererMap receives:
| Prop | Type | Description |
|---|---|---|
value | unknown | Current field value |
onChange | (v: unknown) => void | Update value |
onBlur | () => void | Trigger blur/validation |
error | string | undefined | Validation error message |
state | DerivedFieldState | visible, disabled, required |
field | ResolvedFieldModel | Schema + UI metadata |
options | SelectOption[] | Options from datasource (selects) |
loading | boolean | Datasource is loading |
path | string | Field path (dot notation) |
Array slots
Customize array field chrome (add/remove buttons, item shells):
<FormRuntimeRoot
rootLayoutId="root"
arraySlots={{
ItemShell: ({ children, index }) => (
<div className="rounded border p-4 mb-2">
<span className="text-xs text-gray-500 mb-2 block">Item {index + 1}</span>
{children}
</div>
),
Actions: ({ action, defaultAction }) => (
<div className="mt-2">{defaultAction}</div>
),
}}
/>
Layout renderer map
Replace how a layout container type renders:
import type { LayoutRendererComponent } from "formwright/react";
const CardSection: LayoutRendererComponent = ({ children, layout }) => (
<div className="rounded-lg border shadow-sm p-6 mb-4">
{layout.title && <h3 className="text-lg font-semibold mb-4">{layout.title}</h3>}
{children}
</div>
);
<FormRuntimeRoot
rootLayoutId="root"
layoutRendererMap={{
section: CardSection,
}}
/>