zform-kit
v0.1.1
Published
Auto-generate forms with validation using Zod schema.
Maintainers
Readme
zform-kit
Auto-generate React forms from a Zod schema. Define your fields, validation, and UI in one place — ZForm renders the form for you.
What this package provides
| Feature | Description |
| --------------------------- | ------------------------------------------------------------ |
| Schema-driven forms | Build forms from a z.object() Zod schema |
| Validation | Powered by Zod + react-hook-form + @hookform/resolvers |
| Custom field components | Attach any React input via field() |
| Nested objects | Automatically renders grouped sections |
| Dynamic arrays | Add/remove repeating items (objects or primitives) |
| Container styling | Grid column spans and wrapper classes per field via Tailwind |
| Field defaults | Shared label / input styles for all leaf fields via fieldDefaults |
| Live onChange | Get form values as the user types |
| JSON editor | Paste JSON to fill the form, or export current values |
| Submit UI | Custom label, icon, styles, and loading state |
Exports: ZForm (default), field, getFieldMeta.
Requirements
| Package | Version |
| --------------------- | --------- |
| react | ^19.0.0 |
| react-dom | ^19.0.0 |
| zod | ^4.0.0 |
| react-hook-form | ^7.0.0 |
| @hookform/resolvers | ^5.0.0 |
| lucide-react | ^1.20.0 |
Installation
npm install zform-kitQuick start
1. Create a field component
Your component receives value, onChange, and anything you pass in props (including className). Keep your own base styles, spread props, then merge props.className on top so overrides are added — not replaced:
type InputProps = {
value: string;
onChange: (value: string) => void;
} & React.InputHTMLAttributes<HTMLInputElement>;
function Input({ value, onChange, ...props }: InputProps) {
return (
<input
value={value}
onChange={(e) => onChange(e.target.value)}
{...props}
className={`w-full border p-2 rounded-md ${props.className ?? ""}`}
/>
);
}When you pass props: { className: "placeholder:text-red-500" } on a field, the input keeps w-full border p-2 rounded-md and adds placeholder:text-red-500. For conflicting utilities (e.g. p-2 vs p-4), use tailwind-merge / cn() in your component instead of string concat.
2. Define a Zod schema with field()
Wrap each field with field(zodSchema, meta):
import z from "zod";
import ZForm, { field } from "zform-kit";
const schema = z.object({
name: field(z.string().min(1, "Name is required"), {
label: "Name",
containerClassName: "col-span-1",
component: Input,
}),
email: field(z.email(), {
label: "Email",
containerClassName: "col-span-1",
component: Input,
props: {
className: "placeholder:text-red-500",
},
}),
});3. Render the form
function MyForm() {
return (
<ZForm
zodSchema={schema}
onSubmit={(data) => console.log(data)}
mode="onSubmit"
className="w-full max-w-2xl"
/>
);
}How it works
Zod schema + field() → ZForm → react-hook-form → Your components
↓ ↓
validation grid layout- You define a
z.object()schema using normalz.object(),z.array(), etc. - Each field that needs UI config is wrapped with
field(zodSchema, meta). field()returns the same Zod schema and stores metadata separately (not onzod.meta()).ZFormwalks the schema, looks up metadata per field, and renders the matching components.- On submit, validated data is passed to your
onSubmithandler.
field() usage rules
- Call
field()last — after all Zod chaining:
field(z.string().min(1), { label: "Name" })— correctfield(z.string(), { label: "Name" }).min(1)— wrong (.min()returns a new instance, meta is lost)
- Wrap anything that needs UI config with
field():
- Leaf inputs:
field(z.string(), { component, label, ... }) - Nested sections:
field(z.object({ ... }), { label: "Profile", containerClassName }) - Arrays:
field(z.array(...), { label: "Contacts" }) - Array item cards (optional):
field(z.object({...}), { containerClassName })
- Don't reuse one schema instance across multiple
field()calls (the second call overwrites metadata).
field() meta options
Leaf fields (with component)
| Option | Type | Description |
| -------------------- | ---------------------------------- | -------------------------------------------------------------------------------------- |
| label | string or { text, className? } | Label shown above the field |
| component | React component | Required. Input component to render |
| containerClassName | string | Classes on the engine's field wrapper (label + input + error). Default: "col-span-2" |
| props | object | Extra props spread onto your component (including className overrides) |
| defaultValue | any | Fallback when value is empty |
props and defaultValue are fully typed based on your Zod schema output type and component props.
The engine spreads props (including className) onto your component. Keep your own styles, spread props, then put props.className on top:
function Input({ value, onChange, ...props }) {
return (
<input
value={value}
onChange={(e) => onChange(e.target.value)}
{...props}
className={`w-full border p-2 rounded-md ${props.className ?? ""}`}
/>
);
}
email: field(z.email(), {
label: "Email",
component: Input,
props: {
className: "placeholder:text-red-500", // added above your component’s own classes
},
}),Nested objects
Use field(z.object({ ... }), { label, containerClassName }). Each nested object becomes a section heading with its own grid. There is no depth limit — objects and arrays can nest as deeply as your schema requires.
| Option | Type | Description |
| -------------------- | ---------------------------------- | ---------------------------------------------------------------------------- |
| label | string or { text, className? } | Section heading |
| containerClassName | string | Classes on the section wrapper. Replaces the default section styles when set |
const schema = z.object({
profile: field(
z.object({
name: field(z.string(), { label: "Name", component: Input }),
address: field(
z.object({
street: field(z.string(), { label: "Street", component: Input }),
location: field(
z.object({
city: field(z.string(), { label: "City", component: Input }),
zip: field(z.string(), { label: "ZIP", component: Input }),
}),
{ label: "Location" },
),
}),
{ label: "Address" },
),
}),
{
label: "Profile",
containerClassName: "col-span-2 border-l-2 border-primary/20 pl-4 my-2 bg-muted/30",
},
),
});Dynamic arrays
Use field(z.array(...), { label: "..." }). Users can add/remove items with Add item / trash buttons.
Array of objects:
const schema = z.object({
contacts: field(
z.array(
z.object({
phone: field(z.string(), { label: "Phone", component: Input }),
email: field(z.email(), { label: "Email", component: Input }),
}),
),
{ label: "Contacts" },
),
});Array of primitives (renders a plain text input per item):
const schema = z.object({
tags: field(z.array(z.string()), { label: "Tags" }),
});Arrays and objects together:
const schema = z.object({
projects: field(
z.array(
z.object({
title: field(z.string(), { label: "Title", component: Input }),
tasks: field(
z.array(
z.object({
name: field(z.string(), { label: "Task", component: Input }),
}),
),
{ label: "Tasks" },
),
}),
),
{ label: "Projects" },
),
});Array-specific meta
| Level | Options |
| ----------------------------------- | ------------------------------ |
| Array (field(z.array(), {})) | label, containerClassName |
| Array item (wrapped z.object) | label, containerClassName |
Array items use a single-column grid by default. Pass containerClassName on the array meta to add classes to that items grid (for example "grid-cols-2"). Array item containerClassName is optional and applied to each item card wrapper when set.
ZForm props
<ZForm
zodSchema={schema} // required — z.object() schema
onSubmit={(data) => {}} // required — called with validated data
onChange={(data) => {}} // optional — called on every value change
defaultValues={{}} // optional — initial / reset values
mode="onChange" // optional — react-hook-form validation mode
className="" // optional — merges with form defaults (overrides conflicting utilities)
fieldDefaults={{}} // optional — shared leaf field label / props (see below)
jsonConfig={{}} // optional — JSON editor (see below)
submitConfig={{}} // optional — submit button (see below)
/>className is merged with the form’s built-in classes via tailwind-merge. Conflicting utilities from your app win — you do not need Tailwind’s ! important modifier:
<ZForm
zodSchema={schema}
onSubmit={handleSubmit}
className="p-8 max-w-full bg-transparent"
/>fieldDefaults
Shared defaults for every leaf field (inputs rendered by the form). Section and array headings are unchanged. Per-field field() meta still wins; className values are merged with cn() / tailwind-merge.
| Option | Type | Description |
| ----------------- | ---------------------------- | ------------------------------------------------ |
| label.className | string | Added to every leaf field label |
| props | object (incl. className) | Spread onto every leaf field component |
<ZForm
zodSchema={schema}
onSubmit={handleSubmit}
fieldDefaults={{
label: { className: "text-sm font-medium" },
props: { className: "h-10 rounded-md" },
}}
/>A field-level props.className or label.className merges on top of these defaults.
mode
When validation runs. Same as react-hook-form mode:
| Value | When validation runs |
| ------------- | -------------------------------------- |
| "onChange" | On every change (default) |
| "onSubmit" | On submit only |
| "onBlur" | On blur |
| "onTouched" | After first blur, then on every change |
| "all" | On blur and change |
jsonConfig
| Option | Type | Default | Description |
| ---------- | --------- | ------- | ----------------------------------------- |
| enabled | boolean | false | Show the JSON editor panel |
| liveData | boolean | false | Auto-sync editor with current form values |
<ZForm
zodSchema={schema}
onSubmit={handleSubmit}
jsonConfig={{ enabled: true, liveData: true }}
/>submitConfig
| Option | Type | Default | Description |
| ----------------- | ----------- | ---------------- | ------------------------- |
| disabled | boolean | false | Disable the submit button |
| submitLabel | string | "Save Changes" | Button text |
| submitIcon | ReactNode | save icon | Icon next to the label |
| submitClassName | string | built-in styles | Merged onto button defaults; conflicting utilities win (no ! needed) |
<ZForm
zodSchema={schema}
onSubmit={handleSubmit}
submitConfig={{
submitLabel: "Create account",
submitClassName: "h-12 bg-blue-600 text-white",
}}
/>Styling
Styles are injected with the package. Engine chrome (form shell, labels, errors, submit / add / remove buttons) uses the same default class names as shadcn/ui (new-york). Semantic tokens resolve from CSS variables when present, with built-in fallbacks — if your app already themes shadcn, the form should match without extra work:
| Variable | Used for |
| --- | --- |
| --card, --card-foreground, --border | Form surface |
| --primary, --primary-foreground | Primary actions |
| --secondary, --secondary-foreground | Secondary actions |
| --muted, --muted-foreground | Secondary surfaces / text |
| --accent, --accent-foreground | Hover / accent states |
| --background, --foreground | Base colors |
| --input | Borders on controls |
| --ring | Focus rings |
| --destructive, --destructive-foreground | Remove / error accents |
Override these variables in your app to match your theme.
Overriding layout classes
className, submitClassName, label className, fieldDefaults label/props className, and array containerClassName (when merged) are combined with package defaults using tailwind-merge. Pass normal Tailwind classes — for example className="p-8 max-w-full bg-transparent" — and they replace conflicting defaults. Tailwind’s ! important flag is not required.
Custom components
ZForm always passes value and onChange to your component.
Same prop names? Use it directly:
name: field(z.string(), {
label: "Name",
component: Input,
props: { placeholder: "Enter your name" },
}),Use props for extras like placeholder or disabled — not to rename value / onChange.
Different prop names? Wrap the component and map them. Common with shadcn/ui (checked / onCheckedChange, onValueChange, onSelect, etc.):
function FormCheckbox({ value, onChange }: { value: boolean; onChange: (v: boolean) => void }) {
return <Checkbox checked={value} onCheckedChange={onChange} />;
}Full example
import z from "zod";
import ZForm, { field } from "zform-kit";
const Input = ({ value, onChange, ...props }: any) => (
<input
value={value}
onChange={(e) => onChange(e.target.value)}
{...props}
className={`w-full border p-2 rounded-md ${props.className ?? ""}`}
/>
);
const schema = z.object({
name: field(z.string().min(1), {
label: "Name",
containerClassName: "col-span-1",
component: Input,
}),
email: field(z.email(), {
label: "Email",
containerClassName: "col-span-1",
component: Input,
}),
details: field(
z.object({
phone: field(z.string(), { label: "Phone", component: Input }),
}),
{
label: "Contact details",
containerClassName: "col-span-2 border-l-2 border-primary/20 pl-4 my-2",
},
),
password: field(z.string().min(8), {
label: "Password",
component: Input,
}),
});
export function SignUpForm() {
return (
<ZForm
zodSchema={schema}
mode="onSubmit"
defaultValues={{ name: "", email: "", password: "" }}
onChange={(data) => console.log("changed:", data)}
onSubmit={async (data) => {
await fetch("/api/signup", {
method: "POST",
body: JSON.stringify(data),
});
}}
className="w-full max-w-2xl"
/>
);
}TypeScript
Infer form data from your schema:
type FormData = z.infer<typeof schema>;
<ZForm
zodSchema={schema}
onSubmit={(data: FormData) => {
// data is fully typed
}}
/>;field() provides autocomplete for props and defaultValue based on your component and Zod schema types.
License
See LICENSE. Usage as a dependency is permitted. Copying, modifying, or redistributing the source is not.
