Skip to main content

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 inputsFormField compound parts
Compose array fields manuallyFormArray 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 componentFieldComposer + 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

PartDescription
FormField.RootRequired wrapper. Takes path prop. Provides field context to all descendant parts.
FormField.LabelRenders the field label. Respects accessibility.labelHidden.
FormField.DescriptionRenders description from the field schema.
FormField.ControlWithout children: renders default HTML input. With render function: passes FormFieldControlRenderProps.
FormField.ErrorRenders the validation error message.
FormField.HelpRenders 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

PartPropsDescription
FormArray.RootpathRequired wrapper. Provides array context.
FormArray.HeaderRenders label and description.
FormArray.Itemschildren: (item, index) => ReactNodeIterates over items, calls render function for each.
FormArray.ItemindexItem shell wrapper.
FormArray.Addchildren?Add-item button. Disabled when array is disabled/readonly.
FormArray.Removeindex, 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:

PropTypeDescription
valueunknownCurrent field value
onChange(v: unknown) => voidUpdate value
onBlur() => voidTrigger blur/validation
errorstring | undefinedValidation error message
stateDerivedFieldStatevisible, disabled, required
fieldResolvedFieldModelSchema + UI metadata
optionsSelectOption[]Options from datasource (selects)
loadingbooleanDatasource is loading
pathstringField 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,
}}
/>