npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

zform-kit

v0.1.1

Published

Auto-generate forms with validation using Zod schema.

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-kit

Quick 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
  1. You define a z.object() schema using normal z.object(), z.array(), etc.
  2. Each field that needs UI config is wrapped with field(zodSchema, meta).
  3. field() returns the same Zod schema and stores metadata separately (not on zod.meta()).
  4. ZForm walks the schema, looks up metadata per field, and renders the matching components.
  5. On submit, validated data is passed to your onSubmit handler.

field() usage rules

  1. Call field() last — after all Zod chaining:
  • field(z.string().min(1), { label: "Name" }) — correct
  • field(z.string(), { label: "Name" }).min(1) — wrong (.min() returns a new instance, meta is lost)
  1. 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 })
  1. 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.


Links