Skip to main content

Bring Your Own Components

Formwright renders native HTML inputs by default. They work, but they don't look like your app. Wiring your design system once — so every form gets your components automatically — takes two files and about twenty minutes.

The problem with scattered overrides

Without a central setup, teams end up with one of two patterns.

Pattern A — repeat per form. Every FormRuntimeRoot gets the same fieldRendererMap and fieldSlots copy-pasted. Changing a global style means hunting through every form file.

Pattern B — native inputs forever. The team ships with default HTML inputs, planning to "style them later." Later never comes, and the form looks out of place in the rest of the app.

The fix is to do it once: implement your renderers, bundle them into a FormRoot wrapper, and use FormRoot everywhere. Changing a global style means changing one file.

What you'll build

components/
form/
renderers.tsx ← FieldRendererComponent implementations
FormRoot.tsx ← drop-in replacement for FormRuntimeRoot

Step 1 — implement your renderers

Each renderer is a typed component that receives value, onChange, state, error, and options (for selects). Wire them to your design system.

The examples below use shadcn/ui. Swap the imports for MUI, Radix, Chakra, or anything else.

components/form/renderers.tsx
import type { FieldRendererComponent } from "formwright/react";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import { Checkbox } from "@/components/ui/checkbox";
import { Skeleton } from "@/components/ui/skeleton";
import {
Select, SelectContent, SelectItem,
SelectTrigger, SelectValue,
} from "@/components/ui/select";

export const TextRenderer: FieldRendererComponent = ({
value, onChange, onBlur, error, state, field,
}) => (
<Input
type={field.fieldType === "email" ? "email"
: field.fieldType === "password" ? "password"
: "text"}
value={typeof value === "string" ? value : ""}
onChange={(e) => onChange(e.target.value)}
onBlur={onBlur}
disabled={state.disabled}
readOnly={state.readonly}
aria-invalid={Boolean(error)}
/>
);

export const NumberRenderer: FieldRendererComponent = ({
value, onChange, onBlur, error, state,
}) => (
<Input
type="number"
value={value == null ? "" : String(value)}
onChange={(e) => onChange(e.target.value === "" ? null : Number(e.target.value))}
onBlur={onBlur}
disabled={state.disabled}
aria-invalid={Boolean(error)}
/>
);

export const TextareaRenderer: FieldRendererComponent = ({
value, onChange, onBlur, error, state,
}) => (
<Textarea
value={typeof value === "string" ? value : ""}
onChange={(e) => onChange(e.target.value)}
onBlur={onBlur}
disabled={state.disabled}
aria-invalid={Boolean(error)}
rows={4}
/>
);

export const SelectRenderer: FieldRendererComponent = ({
value, onChange, options, loading, error, state,
}) => {
if (loading) return <Skeleton className="h-9 w-full" />;

return (
<Select
value={typeof value === "string" ? value : ""}
onValueChange={onChange}
disabled={state.disabled}
>
<SelectTrigger aria-invalid={Boolean(error)}>
<SelectValue placeholder="Select…" />
</SelectTrigger>
<SelectContent>
{(options ?? []).map((opt) => (
<SelectItem key={String(opt.value)} value={String(opt.value)}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
};

export const BooleanRenderer: FieldRendererComponent = ({
value, onChange, state, field,
}) => (
<div className="flex items-center gap-2">
<Switch
id={field.path}
checked={Boolean(value)}
onCheckedChange={onChange}
disabled={state.disabled}
/>
</div>
);

export const CheckboxRenderer: FieldRendererComponent = ({
value, onChange, state, field,
}) => (
<Checkbox
id={field.path}
checked={Boolean(value)}
onCheckedChange={(v) => onChange(Boolean(v))}
disabled={state.disabled}
/>
);

export const DateRenderer: FieldRendererComponent = ({
value, onChange, onBlur, error, state,
}) => (
<Input
type="date"
value={typeof value === "string" ? value : ""}
onChange={(e) => onChange(e.target.value)}
onBlur={onBlur}
disabled={state.disabled}
aria-invalid={Boolean(error)}
/>
);

Each renderer is independent. If you want to replace the date picker with a calendar popover later, only that one function changes.

Step 2 — build FormRoot

FormRoot wraps FormRuntimeRoot with your renderer map and global slot overrides baked in. It uses createDefaultRendererMaps() as the base so array fields and layout containers still render correctly without extra setup.

components/form/FormRoot.tsx
import {
FormRuntimeRoot,
createDefaultRendererMaps,
type FieldRendererSlots,
} from "formwright/react";
import {
TextRenderer, NumberRenderer, TextareaRenderer,
SelectRenderer, BooleanRenderer, CheckboxRenderer, DateRenderer,
} from "./renderers";

const { arrayFieldRendererMap, layoutRendererMap } = createDefaultRendererMaps();

// Global label, error, and shell slots — match your design system typography.
const fieldSlots: FieldRendererSlots = {
Shell: ({ children, state }) => (
<div className={`space-y-1.5 ${!state.visible ? "hidden" : ""}`}>
{children}
</div>
),
Label: ({ label, state }) => (
<label className={`text-sm font-medium leading-none ${state.required ? "after:content-['_*'] after:text-destructive" : ""}`}>
{label}
</label>
),
Error: ({ error }) =>
error ? <p className="text-sm text-destructive mt-1">{error}</p> : null,
Help: ({ helpText }) =>
helpText ? <p className="text-xs text-muted-foreground mt-1">{helpText}</p> : null,
};

interface FormRootProps {
rootLayoutId?: string;
}

export function FormRoot({ rootLayoutId = "root" }: FormRootProps) {
return (
<FormRuntimeRoot
rootLayoutId={rootLayoutId}
fieldSlots={fieldSlots}
fieldRendererMap={{
text: TextRenderer,
email: TextRenderer,
password: TextRenderer,
url: TextRenderer,
number: NumberRenderer,
integer: NumberRenderer,
textarea: TextareaRenderer,
select: SelectRenderer,
boolean: BooleanRenderer,
checkbox: CheckboxRenderer,
date: DateRenderer,
}}
arrayFieldRendererMap={arrayFieldRendererMap}
layoutRendererMap={layoutRendererMap}
/>
);
}

Step 3 — use FormRoot everywhere

Replace FormRuntimeRoot with FormRoot. No other changes.

ProfilePage.tsx
import { FormRuntimeProvider, useCreateFormRuntime } from "formwright/react";
import { registerBasicPlugins } from "formwright/plugins";
import { FormRoot } from "@/components/form/FormRoot";
import { profileForm } from "./profile-form";

export function ProfilePage() {
const runtime = useCreateFormRuntime({
form: profileForm,
plugins: registerBasicPlugins(),
});

return (
<FormRuntimeProvider runtime={runtime} hiddenFieldPolicy="clear">
<FormRoot />
</FormRuntimeProvider>
);
}

Every form gets your components. Adding a new field type (e.g. a rich text editor) means adding one renderer to renderers.tsx and one line to the map in FormRoot.tsx.

Overriding one renderer per form

FormRoot sets defaults. A specific form can still override a single renderer without touching the global setup.

import { FormRuntimeRoot, createDefaultRendererMaps } from "formwright/react";
import { MySpecialSelect } from "./MySpecialSelect";

const { fieldRendererMap } = createDefaultRendererMaps();

// Only this form uses MySpecialSelect — everything else stays default.
<FormRuntimeRoot
rootLayoutId="root"
fieldRendererMap={{ ...fieldRendererMap, select: MySpecialSelect }}
/>

Manual composition as an escape hatch

When a specific form needs a layout the auto-renderer can't produce, use FormField directly. You still get runtime state (visibility, required, disabled) and RHF validation — just without the layout engine.

import { FormField } from "formwright/react";
import { Input } from "@/components/ui/input";

<div className="grid grid-cols-2 gap-4">
<FormField.Root path="firstName">
<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.Root>

<FormField.Root path="lastName">
<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.Root>
</div>

FormField still reads from the runtime. Rules, state, and validation all work.

What changes and what stays the same

What changes:

  • Input components are declared once in renderers.tsx, not repeated per form
  • Label, error, and shell styles are declared once in fieldSlots, not repeated per form
  • Adding a field type means one new renderer, one new map entry — nothing else

What stays the same:

  • Individual forms can override specific renderers when needed
  • FormField is always available for fields that need manual layout
  • All renderer props (value, onChange, state, error, options) are the same regardless of which component library you use

See also

  • Customization — decision guide: slots vs renderer map vs manual composition
  • React APIFieldRendererComponent, FieldRendererSlots, createDefaultRendererMaps
  • Custom Renderer — building a reusable renderer with FieldComposer