Skip to main content

Quick Start

Get a working form rendering in under 5 minutes.

Install

npm install formwright@latest react-hook-form

Requires React 18+ and react-hook-form 7.53+.

Build your first form

1. Define fields and layout

profile-form.ts
import { defineForm, field, layout, buildForm } from "formwright/schema";

const nameField = field.text("name", { label: "Name", required: true });
const emailField = field.email("email", { label: "Email" });

export const form = buildForm({
form: defineForm({ id: "profile" }),
fields: [nameField, emailField],
layout: layout.stack("root", [
layout.field(nameField),
layout.field(emailField),
]),
});

2. Create a runtime

profile-form.ts
import { createFormRuntime } from "formwright/core";
import { registerBasicPlugins } from "formwright/plugins";

export const runtime = createFormRuntime({
form,
plugins: registerBasicPlugins(),
});

Create the runtime outside React — once per form definition, not on every render.

3. Render it

ProfileForm.tsx
import { FormRuntimeProvider, FormRuntimeRoot } from "formwright/react";
import { form, runtime } from "./profile-form";

export function ProfileForm() {
return (
<FormRuntimeProvider runtime={runtime}>
<FormRuntimeRoot rootLayoutId="root" />
</FormRuntimeProvider>
);
}

Formwright renders native HTML inputs by default. No additional setup needed.

Handle submit

FormRuntimeProvider wraps React Hook Form's FormProvider. Access handleSubmit from useFormContext inside any child component:

ProfileForm.tsx
import { useFormContext } from "react-hook-form";
import { FormRuntimeProvider, FormRuntimeRoot } from "formwright/react";
import { runtime } from "./profile-form";

function FormBody() {
const { handleSubmit } = useFormContext();

function onSubmit(values: Record<string, unknown>) {
console.log(values);
// { name: "Alice", email: "alice@example.com" }
}

return (
<form onSubmit={handleSubmit(onSubmit)}>
<FormRuntimeRoot rootLayoutId="root" />
<button type="submit">Save</button>
</form>
);
}

export function ProfileForm() {
return (
<FormRuntimeProvider runtime={runtime}>
<FormBody />
</FormRuntimeProvider>
);
}

Pre-fill values

Pass initialValues for edit forms:

<FormRuntimeProvider
runtime={runtime}
initialValues={{ name: "Alice", email: "alice@example.com" }}
>
<FormBody />
</FormRuntimeProvider>

Bring Your Own Components (BYOC)

The defaults are plain native HTML — no styling opinions. Three ways to replace them.

When to use which

SituationOption
You control layout — fields live inside your own page structureAFormField compound
Schema drives layout, you just want to restyle labels/errors globallyB — Slots
Schema drives layout, you want to swap inputs by type (all selects, all text)CfieldRendererMap
Mix: schema-driven layout + a few complex fields (date picker, signature)B + C — Slots + renderer map

FormField is a compound component — compose each field yourself using declarative parts. FormField.Control accepts a render function that gives you value, onChange, onBlur, error, and state. No custom renderer boilerplate.

ProfileForm.tsx
import { FormRuntimeProvider, FormField } from "formwright/react";
import { Input } from "@/components/ui/input"; // shadcn, Mantine, Radix, anything

export function ProfileForm() {
return (
<FormRuntimeProvider runtime={runtime}>
<form onSubmit={...}>
<div className="space-y-4">
<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.Root>

<FormField.Root path="email">
<FormField.Label />
<FormField.Control /> {/* renders default HTML input */}
<FormField.Error />
</FormField.Root>
</div>
<button type="submit">Save</button>
</form>
</FormRuntimeProvider>
);
}

Parts: Root (required, wraps one field path), Label, Description, Control, Error, Help. Use any component library inside Control — shadcn/ui, Mantine, Radix, Ant Design, MUI, your own design system.

Option B: Style via slots (wrap the defaults)

fieldSlots applies global styling to the field shell when using FormRuntimeRoot. Use this when the schema drives your layout and you just want to restyle labels, errors, or wrappers.

<FormRuntimeRoot
rootLayoutId="root"
fieldSlots={{
Label: ({ label, state }) => (
<label className="text-sm font-medium text-gray-700">
{label}
{state.required && <span className="ml-1 text-red-500">*</span>}
</label>
),
Error: ({ error }) =>
error ? <p className="text-sm text-red-600">{error}</p> : null,
}}
/>

The Control slot receives defaultControl — wrap the native input without replacing it entirely:

fieldSlots={{
Control: ({ defaultControl, error }) => (
<div className={`rounded border px-3 py-2 ${error ? "border-red-500" : "border-gray-300"}`}>
{defaultControl}
</div>
),
}}

Option C: Replace inputs by type (shadcn/ui, Radix, Mantine, …)

fieldRendererMap swaps the entire renderer for a field type. Wire any headless or component-library input to value / onChange / onBlur — that's all Formwright needs.

shadcn/ui (most common):

renderers.tsx
import { Input } from "@/components/ui/input";
import { type FieldRendererComponent } from "formwright/react";

export const ShadcnInput: FieldRendererComponent = ({ value, onChange, onBlur, error, state, field }) => (
<div className="flex flex-col gap-1">
<label className="text-sm font-medium">{field.uiField?.label}</label>
<Input
value={typeof value === "string" ? value : ""}
onChange={(e) => onChange(e.target.value)}
onBlur={onBlur}
disabled={state.disabled}
placeholder={field.uiField?.placeholder}
aria-invalid={Boolean(error)}
/>
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
);

Mantine:

import { TextInput } from "@mantine/core";
import { type FieldRendererComponent } from "formwright/react";

export const MantineInput: FieldRendererComponent = ({ value, onChange, onBlur, error, state, field }) => (
<TextInput
label={field.uiField?.label}
value={typeof value === "string" ? value : ""}
onChange={(e) => onChange(e.target.value)}
onBlur={onBlur}
disabled={state.disabled}
placeholder={field.uiField?.placeholder}
error={error}
/>
);

Then register for whichever field types you want to replace:

ProfileForm.tsx
<FormRuntimeRoot
rootLayoutId="root"
fieldRendererMap={{
text: ShadcnInput, // or MantineInput
email: ShadcnInput,
}}
/>

Mix both: fieldSlots for global label/error chrome, fieldRendererMap only for types that need a custom component. Any component library works — Radix UI, Chakra, Ant Design, MUI, or your own internal design system — as long as it accepts controlled value and onChange props.

See Customization for the full guide including shadcn/ui, Radix, and design system patterns.

Next steps

  • Schema — all field types, layout, rules, and data sources
  • Conditional Fields — show/hide fields based on values
  • Customization — slots, renderer maps, design system patterns
  • Validation — schema rules, Zod, cross-field validation