Fields
Live examples of every shipped @zeno-lib/forms field
Every shipped field handles its own label, description, error message, and
accessibility wiring. Destructure from the form returned by useForm,
drop it inside <Form>, and the name prop autocompletes against your
schema (or defaultValues when no schema is passed).
const form = useForm({ schema })
const { InputField, SubmitButton } = form
<FormProvider form={form}>
<Form>
<FieldGroup>
<InputField name="…" />
</FieldGroup>
<SubmitButton>Save</SubmitButton>
</Form>
</FormProvider>Every field accepts an optional validators prop forwarded to TanStack
Form's <Field> for per-field overrides. See
Validation modes.
Input Field
<InputField
description="Shown next to your avatar."
label="Display name"
name="name"
placeholder="Ada Lovelace"
/>Email Field
Preset over InputField with type="email", inputMode="email", and
autoComplete="email". Defaults label to "Email" and name to
"email" (when your form has that key).
<EmailField placeholder="you@zeno.dev" />Password Field
Preset over InputField with type="password" and
autoComplete="current-password". Pass autoComplete="new-password" on
signup. Defaults label to "Password" and name to "password".
<PasswordField autoComplete="new-password" />Text Area Field
<TextAreaField
description="160 characters max."
label="Bio"
name="bio"
placeholder="I build things on the web."
/>Number Field
Stores the input as number (or undefined when empty), so your form
state stays typed without manual coercion.
<NumberField label="Age" min={13} name="age" />Composing addons (input groups)
InputField, EmailField, PasswordField, NumberField, and
TextAreaField accept addon children that render inside an
<InputGroup>:
prefix icons, suffix actions, block-level toolbars. Without children the
field renders a plain input.
<InputGroupAddon align> accepts inline-start, inline-end,
block-start, and block-end. Visual order is driven by CSS, so the JSX
order doesn't matter.
Prefix icon
import { InputGroupAddon } from "@zeno-lib/ui/input-group"
import { MailIcon } from "lucide-react"
<EmailField placeholder="you@zeno.dev">
<InputGroupAddon align="inline-start">
<MailIcon />
</InputGroupAddon>
</EmailField>Prefix and suffix text
import { InputGroupAddon, InputGroupText } from "@zeno-lib/ui/input-group"
<InputField label="Handle" name="handle" placeholder="acme">
<InputGroupAddon align="inline-start">
<InputGroupText>https://</InputGroupText>
</InputGroupAddon>
<InputGroupAddon align="inline-end">
<InputGroupText>.zeno.dev</InputGroupText>
</InputGroupAddon>
</InputField>Block toolbar
block-start and block-end render above and below the input inside the
same focus ring. Useful for textarea toolbars, helper rows, character
counters.
import { InputGroupAddon, InputGroupButton } from "@zeno-lib/ui/input-group"
import { BoldIcon, ItalicIcon } from "lucide-react"
<TextAreaField label="Bio" name="bio" placeholder="I build things on the web.">
<InputGroupAddon align="block-start">
<InputGroupButton type="button">
<BoldIcon />
</InputGroupButton>
<InputGroupButton type="button">
<ItalicIcon />
</InputGroupButton>
</InputGroupAddon>
</TextAreaField>Async-validation indicator
@zeno-lib/forms ships <ValidationSpinner /> for the common case: a spinner
appears in the input while an async validator is in flight, and clears
when it resolves. See the
Async submission example
for a full setup.
import { ValidationSpinner } from "@zeno-lib/forms"
<EmailField
placeholder="Try taken@example.com"
validators={{
onChangeAsync: async ({ value }: { value: string }) => {
const taken = await checkEmailAvailable(value)
return taken ? "Email already in use" : undefined
},
onChangeAsyncDebounceMs: 500,
}}
>
<ValidationSpinner />
</EmailField>For other state-dependent addons, write a small component that calls
useFieldContext() and returns an addon (or null). The field's
children render inside the field context, so subscribing composes
naturally.
Don't toggle children directly.
<EmailField>{cond && <Addon />}</EmailField>swaps the field between plain<Input>and<InputGroup>wrappers, remounting the input and losing focus. Always render the child element, let it returnnullinternally when not needed (the wayValidationSpinnerdoes).
Select Field
Wraps @zeno-lib/ui/select. Pass <SelectItem> children for the options. For
the common case of database rows where the ID is a number, see
Numeric IDs.
import { SelectItem } from "@zeno-lib/ui/select"
<SelectField label="Role" name="role" placeholder="Pick a role">
<SelectItem value="engineer">Engineer</SelectItem>
<SelectItem value="designer">Designer</SelectItem>
<SelectItem value="manager">Manager</SelectItem>
</SelectField>Checkbox Field
<CheckboxField
description="You can revoke this at any time."
label="I accept the terms of service"
name="terms"
/>Switch Field
<SwitchField
description="Get notified when something needs your attention."
label="Push notifications"
name="pushNotifications"
/>Radio Group Field
Wraps @zeno-lib/ui/radio-group. <RadioGroupFieldItem> pairs each radio
with its label and htmlFor so you don't wire IDs by hand.
import { RadioGroupFieldItem } from "@zeno-lib/forms/fields"
<RadioGroupField label="Plan" name="plan">
<RadioGroupFieldItem value="free">Free</RadioGroupFieldItem>
<RadioGroupFieldItem value="pro">Pro</RadioGroupFieldItem>
<RadioGroupFieldItem value="team">Team</RadioGroupFieldItem>
</RadioGroupField>Slider Field
Wraps @zeno-lib/ui/slider. The form value is number for a single thumb,
number[] for a range (pick by typing your defaultValues). Pass
formatValue to render a live readout next to the label.
<SliderField
description="Used for notification chimes."
formatValue={(v) => `${Array.isArray(v) ? v[0] : v}%`}
label="Volume"
max={100}
min={0}
name="volume"
step={1}
/>OTP Field
Wraps @zeno-lib/ui/input-otp. Defaults to a 6-slot group. Override with
maxLength, restrict input with pattern (a string regex), or pass
children to insert a separator between groups.
<OtpField
description="We sent a code to your email."
label="Verification code"
name="code"
pattern="^[0-9]+$"
/>Date Picker Field
Composes @zeno-lib/ui/popover and @zeno-lib/ui/calendar. The form value is
Date | undefined. The trigger formats the selected date via
toLocaleDateString by default; pass formatValue to override or
calendarProps to forward options (disabled, locale, …) to the
underlying <Calendar>.
<DatePickerField
description="Used for age-restricted features."
label="Birthday"
name="birthday"
/>Combobox Field
Wraps @zeno-lib/ui/combobox. Single-select with client-side filtering as the
user types. Pass plain strings for string[] items, or { value, label }
objects when the value isn't a string — see
Numeric IDs.
const FRAMEWORKS = ["Next.js", "Remix", "SvelteKit", "Nuxt.js", "Astro"]
<ComboboxField
description="Filter as you type."
emptyMessage="No framework matches."
items={FRAMEWORKS}
label="Favourite framework"
name="framework"
placeholder="Search frameworks…"
/>| Prop | Type | What it does |
|---|---|---|
items | T[] (string or { value, label }) | Options shown in the dropdown. Filtered client-side unless filter={null}. |
renderItem | (item: T) => ReactNode | Override per-row rendering. |
emptyMessage | ReactNode | Shown when filtering produces zero matches. |
showClear | boolean | Show the clear button when a value is selected. Defaults to true. |
placeholder | string | Input placeholder when the field is empty. |
inputValue | string | Controlled search text. Pair with onInputValueChange. |
onInputValueChange | (value: string) => void | Fires on every keystroke in the search input. |
filter | ((item, query) => boolean) | null | Replace the built-in filter, or null for server-driven search. |
loading | boolean | Show a spinner inside the input while options load. |
For server-driven search with debouncing, see Async-search Combobox.
Custom fields
When the gallery doesn't fit, build your own on top of useFieldContext<T>()
and drop it inside <AppField> (destructured from form). You get the
same typed field state and accessibility plumbing the shipped fields use.
"use client"
import { useFieldContext, useIsInvalid } from "@zeno-lib/forms"
import { Field, FieldError, FieldLabel } from "@zeno-lib/ui/field"
import { Slider } from "@zeno-lib/ui/slider"
function VolumeField({ label }: { label: string }) {
const field = useFieldContext<number>()
const isInvalid = useIsInvalid(field)
const value = field.state.value ?? 0
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor={field.name}>
{label}: {value}
</FieldLabel>
<Slider
id={field.name}
max={100}
min={0}
onValueChange={(next) =>
field.handleChange(Array.isArray(next) ? (next[0] ?? 0) : next)
}
value={[value]}
/>
{isInvalid && <FieldError errors={field.state.meta.errors} />}
</Field>
)
}
// In a form:
const { AppField } = form
<AppField name="volume">
{() => <VolumeField label="Volume" />}
</AppField>AppField is also the escape hatch when you want direct access to a
shipped field's instance.