Opinionated Zod-empowered forms for React.
Ozef builds a typed form component from a z.object(), z.strictObject(), or
z.looseObject() schema. It owns form state, validation state, submit state, and
field wiring while letting you bring your own inputs, buttons, labels, and
layout.
npm i ozef zod jotaiOzef expects React, React DOM, Jotai, and Zod to be installed by the app. The current peer range targets Zod 4.
Ozef currently supports this practical Zod subset:
z.string()z.templateLiteral()z.number()z.boolean()z.enum(["a", "b"])for radio groupsz.union([z.literal("a"), z.literal("b")])for native selects.optional(),.nullable(), and.default()wrappers around the above
Ozef infers native input types from these Zod string formats, including their
equivalent chained z.string() validators:
| Zod format | Native input |
|---|---|
z.email() |
type="email" |
z.url() and z.httpUrl() |
type="url" |
z.e164() |
type="tel" |
z.iso.date() |
type="date" |
z.iso.time() |
type="time" with step="any" |
Other string formats—including z.uuid(), z.guid(), and
z.iso.datetime()—render as type="text". A native datetime-local input
does not accept the timezone suffix required by Zod's default ISO datetime
schema.
Other Zod types may work only if they behave like one of those primitives, but they are not part of the supported API yet.
import ozef from "ozef";
import { z } from "zod";
const ProfileForm = ozef({
schema: z.object({
name: z.string().min(2, "Name is too short"),
age: z.number().min(1),
newsletter: z.boolean(),
}),
defaults: {
name: "Ada",
age: 36,
newsletter: true,
},
});
export function Profile() {
return (
<ProfileForm
onSubmit={async (data) => {
// data is typed as { name: string; age: number; newsletter: boolean }
await saveProfile(data);
}}
>
<ProfileForm.Field name="name" />
<ProfileForm.Error name="name" />
<ProfileForm.Field name="age" />
<ProfileForm.Error name="age" />
<ProfileForm.Field name="newsletter" />
<ProfileForm.Submit>Save</ProfileForm.Submit>
</ProfileForm>
);
}The name-based API works with any schema key, including keys like
first_name, billing.email, or keys you only know dynamically.
Ozef also generates PascalCase components for simple schemas:
<ProfileForm.Field.Name />
<ProfileForm.Error.Name />
<ProfileForm.Field.Age />Use this when the schema keys are friendly component names. Use
<Form.Field name="..."> when keys are dynamic or not valid component-looking
identifiers.
Use z.enum for radio values:
const SettingsForm = ozef({
schema: z.object({
size: z.enum(["small", "large"]),
}),
});
<SettingsForm.Field name="size">
<SettingsForm.Radio name="size" value="small" />
<SettingsForm.Radio name="size" value="large" />
</SettingsForm.Field>;Use a union of string literals for native selects:
const ColorForm = ozef({
schema: z.object({
color: z.union([z.literal("red"), z.literal("blue")]),
}),
});
<ColorForm.Field name="color">
<ColorForm.Option name="color" value="red" />
<ColorForm.Option name="color" value="blue" />
</ColorForm.Field>;The generated PascalCase version still works:
<ColorForm.Field.Color>
<ColorForm.Field.Color.Red />
<ColorForm.Field.Color.Blue />
</ColorForm.Field.Color>Ozef owns form state and validation; your component owns the markup and visual design. A custom component should use one of two integration patterns:
- Native-compatible controls—such as a plain
<input>or shadcnInput—spread Ozef'sinputProps. Those props already contain the current value, inferred type, accessibility state, and wiredonChange/onBlurhandlers. - Controlled composite controls—such as shadcn
Checkbox,Select, orRadioGroup—translatefield.value,field.setValue(), andfield.touch()into that component's API.
| Ozef option | Replaces | Use it for |
|---|---|---|
Input |
Every generated text, number, and checkbox field | Native inputs, shadcn Input, and a branch to shadcn Checkbox |
InputRadio |
Each generated radio item | A native-style radio item; not a RadioGroup root |
Select |
The complete generated native select | shadcn/Radix Select or another controlled select |
Option |
Each native <option> |
Styling/wrapping native selects only |
Error |
Form.Error and Form.Errors output |
Design-system error text |
Submit |
Form.Submit |
Design-system buttons and loading states |
Every field-like slot receives two Ozef metadata props:
field:{ name, value, error, invalid, touched, required, setValue, touch }inputProps: the native props Ozef would put on its default element, includingname,type,valueorchecked, accessibility attributes,onChange, andonBlur
Keep these rules in mind:
- Spread
inputProps, not the entire custom component props object, onto a DOM element. The latter also containsfield,inputProps, and your custom props. - Custom props passed to
<Form.Field>, such aslabelorhint, also appear insideinputProps. Remove them before spreading onto a DOM element. - With a native-compatible input, do not also call
field.setValue(); theinputProps.onChangehandler already does it. - With a composite control, call
field.setValue(nextValue)when it changes andfield.touch()when the user has interacted or leaves the control. - Render errors either inside the custom field with
field.erroror separately with<Form.Error name="..." />. Doing both displays the same error twice.
This is the smallest complete adapter for a component that ends in a native
<input>:
import type { OzefInputProps } from "ozef";
import ozef from "ozef";
import { z } from "zod";
type TextInputProps = OzefInputProps & {
label: string;
};
function TextInput({ label, field, inputProps }: TextInputProps) {
const { label: _label, ...nativeInputProps } = inputProps ?? {};
const errorId = nativeInputProps.id
? `${nativeInputProps.id}-error`
: undefined;
return (
<div>
<label htmlFor={nativeInputProps.id}>{label}</label>
<input
{...nativeInputProps}
aria-describedby={
field?.error ? errorId : nativeInputProps["aria-describedby"]
}
className={
field?.invalid
? `${nativeInputProps.className ?? ""} input-error`.trim()
: nativeInputProps.className
}
/>
{field?.error ? (
<p id={errorId} role="alert">
{field.error}
</p>
) : null}
</div>
);
}
const LoginForm = ozef({
schema: z.object({
email: z.email(),
}),
Input: TextInput,
});
<LoginForm>
<LoginForm.Field name="email" id="email" label="Email" />
<LoginForm.Submit>Sign in</LoginForm.Submit>
</LoginForm>;Because this component renders field.error itself, do not also render
<LoginForm.Error name="email" /> for this field.
shadcn components are source code in your application, so you can adapt them directly. Add the primitives you need, for example:
bunx shadcn@latest add input checkbox label button select radio-groupshadcn Input is native-compatible, but shadcn Checkbox uses
checked/onCheckedChange. One Input slot can handle both by branching on
Ozef's generated inputProps.type:
import type { ComponentProps } from "react";
import { Checkbox } from "@/components/ui/checkbox";
import { Input as UiInput } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import type { OzefInputProps } from "ozef";
import ozef from "ozef";
import { z } from "zod";
type FieldInputProps = OzefInputProps &
ComponentProps<"input"> & {
label: string;
};
function FieldInput({ field, inputProps, label }: FieldInputProps) {
const { label: _label, ...nativeInputProps } = inputProps ?? {};
const errorId = nativeInputProps.id
? `${nativeInputProps.id}-error`
: undefined;
if (nativeInputProps.type === "checkbox") {
return (
<div className="space-y-2">
<div className="flex items-center gap-2">
<Checkbox
id={nativeInputProps.id}
name={nativeInputProps.name}
checked={field?.value === true}
disabled={nativeInputProps.disabled}
required={field?.required}
aria-invalid={field?.invalid}
aria-describedby={field?.error ? errorId : undefined}
onCheckedChange={(checked) => {
field?.setValue(checked === true);
field?.touch();
}}
onBlur={() => field?.touch()}
/>
<Label htmlFor={nativeInputProps.id}>{label}</Label>
</div>
{field?.error ? (
<p id={errorId} role="alert" className="text-sm text-destructive">
{field.error}
</p>
) : null}
</div>
);
}
return (
<div className="space-y-2">
<Label htmlFor={nativeInputProps.id}>{label}</Label>
<UiInput
{...nativeInputProps}
aria-invalid={field?.invalid}
aria-describedby={
field?.error ? errorId : nativeInputProps["aria-describedby"]
}
/>
{field?.error ? (
<p id={errorId} role="alert" className="text-sm text-destructive">
{field.error}
</p>
) : null}
</div>
);
}
const PreferencesForm = ozef({
schema: z.object({
email: z.email(),
marketing: z.boolean(),
}),
Input: FieldInput,
});
<PreferencesForm>
<PreferencesForm.Field name="email" id="email" label="Email" />
<PreferencesForm.Field
name="marketing"
id="marketing"
label="Email updates"
/>
<PreferencesForm.Submit>Save</PreferencesForm.Submit>
</PreferencesForm>;If your project uses shadcn's higher-level Field layout components, place the
same adapter logic inside that layout; Ozef should remain the owner of value and
validation state.
For shadcn/ui or Radix Select, map the Ozef field state into Radix's controlled
value and onValueChange props instead of spreading inputProps onto every
part:
import {
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Select as UiSelect,
} from "@/components/ui/select";
import type { OzefInputProps } from "ozef";
import ozef from "ozef";
import { z } from "zod";
type SelectInputProps = OzefInputProps & {
placeholder?: string;
options?: Array<{ label: string; value: string }>;
};
function SelectInput({
field,
inputProps,
placeholder = "Select an option",
options = [],
}: SelectInputProps) {
return (
<UiSelect
name={inputProps?.name}
value={String(field?.value ?? "")}
disabled={inputProps?.disabled}
required={field?.required}
onValueChange={(value) => {
field?.setValue(value);
field?.touch();
}}
onOpenChange={(open) => {
if (!open) {
field?.touch();
}
}}
>
<SelectTrigger
id={inputProps?.id}
aria-invalid={field?.invalid}
aria-required={field?.required}
onBlur={() => field?.touch()}
>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</UiSelect>
);
}
const TicketForm = ozef({
schema: z.object({
priority: z.union([
z.literal("low"),
z.literal("normal"),
z.literal("high"),
]),
}),
Select: SelectInput,
});
<TicketForm>
<TicketForm.Field
name="priority"
id="priority"
placeholder="Priority"
options={[
{ label: "Low", value: "low" },
{ label: "Normal", value: "normal" },
{ label: "High", value: "high" },
]}
/>
<TicketForm.Error name="priority" />
<TicketForm.Submit>Create ticket</TicketForm.Submit>
</TicketForm>;Because this replaces the entire native select, render SelectItem components
inside the adapter. Do not also render TicketForm.Option children.
For shadcn/ui or Radix RadioGroup, control the whole group with
Form.useField(name). InputRadio replaces one radio item at a time, so it is
not the right abstraction for a composite RadioGroup root:
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import ozef from "ozef";
import { z } from "zod";
type Frequency = "daily" | "weekly" | "never";
const NotificationForm = ozef({
schema: z.object({
frequency: z.enum(["daily", "weekly", "never"]),
}),
});
function FrequencyField() {
const frequency = NotificationForm.useField("frequency");
return (
<fieldset>
<legend>Notification frequency</legend>
<RadioGroup
name={frequency.name}
value={frequency.value ?? ""}
required={frequency.required}
aria-invalid={frequency.invalid}
aria-describedby={frequency.error ? "frequency-error" : undefined}
onValueChange={(value) => {
frequency.setValue(value as Frequency);
frequency.touch();
}}
onBlur={() => frequency.touch()}
>
{[
["daily", "Daily"],
["weekly", "Weekly"],
["never", "Never"],
].map(([value, label]) => (
<div key={value} className="flex items-center gap-2">
<RadioGroupItem id={`frequency-${value}`} value={value} />
<Label htmlFor={`frequency-${value}`}>{label}</Label>
</div>
))}
</RadioGroup>
{frequency.error ? (
<p
id="frequency-error"
role="alert"
className="text-sm text-destructive"
>
{frequency.error}
</p>
) : null}
</fieldset>
);
}
<NotificationForm>
<FrequencyField />
<NotificationForm.Submit>Save</NotificationForm.Submit>
</NotificationForm>;This example renders frequency.error inline. Alternatively, remove that
paragraph and use <NotificationForm.Error name="frequency" />.
For native radio items, replace the InputRadio slot and keep using
Form.Radio:
import type { ComponentProps } from "react";
import type { OzefInputProps } from "ozef";
import ozef from "ozef";
import { z } from "zod";
type RadioInputProps = OzefInputProps &
ComponentProps<"input"> & {
label: string;
};
function RadioInput({ label, field, inputProps }: RadioInputProps) {
const { label: _label, ...nativeInputProps } = inputProps ?? {};
return (
<label data-invalid={field?.invalid || undefined}>
<input {...nativeInputProps} />
{label}
</label>
);
}
const SizeForm = ozef({
schema: z.object({
size: z.enum(["small", "large"]),
}),
InputRadio: RadioInput,
});
<SizeForm.Field name="size">
<SizeForm.Radio name="size" value="small" label="Small" />
<SizeForm.Radio name="size" value="large" label="Large" />
</SizeForm.Field>;The Option slot only applies to native select fields. With shadcn Select, put
SelectItem rendering inside your custom Select component instead.
import type { ComponentProps } from "react";
import ozef from "ozef";
import { z } from "zod";
function NativeOption({
name: _name,
...props
}: ComponentProps<"option"> & { name?: string }) {
return <option {...props} />;
}
const NativeThemeForm = ozef({
schema: z.object({
theme: z.union([z.literal("system"), z.literal("dark")]),
}),
Option: NativeOption,
});
<NativeThemeForm.Field name="theme">
<NativeThemeForm.Option name="theme" value="system">
System
</NativeThemeForm.Option>
<NativeThemeForm.Option name="theme" value="dark">
Dark
</NativeThemeForm.Option>
</NativeThemeForm.Field>;Use Error and Submit for design-system error text and buttons:
import type { ComponentProps } from "react";
import ozef from "ozef";
import { z } from "zod";
import { Button } from "@/components/ui/button";
function FieldError({
error,
...props
}: ComponentProps<"span"> & { error?: string }) {
return (
<span {...props} role="alert" className="text-sm text-destructive">
{error}
</span>
);
}
function SubmitButton({
submitting,
children,
...props
}: ComponentProps<typeof Button> & { submitting?: boolean }) {
return (
<Button {...props} type="submit" disabled={submitting || props.disabled}>
{submitting ? "Saving..." : children}
</Button>
);
}
const AccountForm = ozef({
schema: z.object({
email: z.string().email(),
}),
Error: FieldError,
Submit: SubmitButton,
});
<AccountForm.Field name="email" />
<AccountForm.Error name="email" />
<AccountForm.Submit>Save</AccountForm.Submit>;Legacy metadata props like errorful and radioValue are still passed for
existing custom inputs, but new components should prefer field.invalid,
field.error, and inputProps.
Use Form.useField(name) when composing custom field UI:
function EmailPreview() {
const email = LoginForm.useField("email");
return (
<button type="button" onClick={() => email.setValue("ada@example.com")}>
Use {email.value || "default email"}
</button>
);
}Use Form.useForm() for form-level controls:
function FormToolbar() {
const form = LoginForm.useForm();
return (
<>
<button type="button" onClick={form.reset}>
Reset
</button>
<button
type="button"
onClick={() => form.setError("email", "Email is already taken")}
>
Set server error
</button>
{form.submitting ? "Saving..." : null}
</>
);
}onSubmit receives utility helpers. Use setError for server-side errors:
const AccountForm = ozef({
schema: z.object({
email: z.string().email(),
}),
});
<AccountForm
onSubmit={async (data, utils) => {
const result = await createAccount(data);
if (!result.ok) {
utils.setError("email", "Email is already taken");
utils.setError("submission", "Please fix the highlighted fields");
}
}}
>
<AccountForm.Field name="email" />
<AccountForm.Error name="email" />
<AccountForm.Error name="submission" />
<AccountForm.Submit>Create account</AccountForm.Submit>
</AccountForm>;Render every visible error with:
<AccountForm.Errors />See examples/vite-react for a minimal Vite app using the current recommended
API with custom inputs, hooks, field errors, and submit errors.