Fields
Live examples of every shipped @zeno-lib/forms field
Every shipped field handles its own label, description, and error message,
and sets aria-invalid and aria-describedby on its control. Destructure a
field from the form returned by useForm and drop it inside <Form>. Its
name prop autocompletes against your schema (or defaultValues when you
pass no schema).
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, which it forwards to
TanStack Form's <Field> to add checks to that one field. See
Validation modes.
name is required on every field, presets included. Array rows use
TanStack Form's bracket syntax (name="members[0].name").
Test hooks
Every field's root <Field> element renders data-field={name}, a stable
hook for end-to-end tests that doesn't depend on labels or copy:
const email = page.locator('[data-field="email"]')
await expect(email.getByRole("textbox")).toHaveAttribute("aria-invalid", "true")
await expect(page.locator('[data-field="members[1].name"]')).toBeVisible()The root also carries data-invalid, and the control inside carries
aria-invalid="true" while its error is shown.
Input field
<InputField
description="Shown next to your avatar."
label="Display name"
name="name"
placeholder="Ada Lovelace"
/>Email field
EmailField is a preset over InputField that sets type="email",
inputMode="email", and autoComplete="email", and turns off
auto-capitalisation, autocorrect and spell check. It defaults label to
"Email"; name is required, as on every other field.
<EmailField name="email" placeholder="you@zeno.dev" />Password field
PasswordField is a preset over InputField that sets type="password"
and autoComplete="current-password". Pass autoComplete="new-password" on
a signup form. It defaults label to "Password"; name is required.
<PasswordField autoComplete="new-password" name="password" />Text area field
<TextAreaField
description="160 characters max."
label="Bio"
name="bio"
placeholder="I build things on the web."
/>Number field
NumberField stores the input as a number (its
empty value
when emptied), so
you never convert the input's string yourself. It sets inputMode="decimal",
so phone keypads keep the decimal key; pass inputMode="numeric" for whole
numbers.
<NumberField label="Age" min={13} name="age" />Money field
MoneyField is a text input that shows the currency as an addon. It
re-inserts the locale's thousands separators as the user types and keeps the
caret in place. The form value is a number, or the field's
empty value
when emptied (null when the schema allows nothing else). The
field also accepts pasted amounts in other locales' formats: CHF 1'234.50,
1.234,50 € and 1,234.50 all become 1234.5, whatever the input's locale.
On blur, the field pads a non-integer amount to the currency's minor units
(12.5 becomes 12.50); integers stay bare.
<MoneyField currency="CHF" label="Budget" locale="de-CH" name="budget" />| Prop | Type | What it does |
|---|---|---|
currency | string | ISO 4217 code for the addon ("CHF", "EUR"). Omit for a bare amount. |
currencyDisplay | "symbol" | "narrowSymbol" | "code" | "name" | How the addon reads. Defaults to "symbol". |
currencyPosition | "start" | "end" | Which side the addon sits on. Defaults to the locale's convention (CHF 1’234 in de-CH, 1.234 € in de-DE). |
locale | string | BCP 47 locale for the separators. Defaults to the runtime locale. |
fractionDigits | number | Decimals allowed. Defaults to the currency's minor units (2 for CHF, 0 for JPY), else 2. |
allowNegative | boolean | Accept a leading minus. Defaults to false. |
min / max | number | Clamp on blur. For an error message instead, validate in the schema. |
While the user types, only the locale's decimal separator starts the
fraction (. in de-CH, , in de-DE), and the field drops any other
non-digit character.
Percentage field
PercentageField uses the same formatting hook as MoneyField, with a %
addon. Pick what the form value means with scale:
scale="percent"(default): the value is the number shown, so12.5shows as12.5 %.scale="fraction": the value is a ratio, so0.125shows as12.5 %, which is what most APIs and databases store.
<PercentageField label="Interest rate" name="rate" scale="fraction" />fractionDigits (default 2) counts the decimals of the shown percentage.
min/max clamp on blur, in the form value's scale. allowNegative
defaults to false.
Year field
YearField takes a four-digit integer year (number | null). It accepts
digits only, with no thousands separator, and sets inputMode="numeric".
min/max clamp the value on blur.
<YearField label="Founded" max={2100} min={1900} name="founded" />Formatting your own inputs
The three fields above are thin wrappers over a headless hook that ships on
npm. Spread its result on any <input>:
import { useFormattedNumber } from "@zeno-lib/forms/lib/use-formatted-number"
const inputProps = useFormattedNumber({
locale: "de-CH",
maximumFractionDigits: 3,
onBlur: field.handleBlur,
onValueChange: field.handleChange,
value: field.state.value,
})
<InputGroupInput {...inputProps} />It returns value, onChange, onBlur, onPaste, inputMode and
type="text". It also takes padFraction, useGrouping, allowNegative,
maxIntegerDigits, min/max, and a toDisplay/fromDisplay pair that
maps the stored value to the shown number and back (that's how
scale="fraction" works). The pure helpers
(parseLocaleNumber, formatNumber, …) live in
@zeno-lib/forms/lib/formatted-number.
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. CSS sets the visual order, so the JSX order
doesn't matter.
Prefix icon
import { InputGroupAddon } from "@zeno-lib/ui/input-group"
import { MailIcon } from "lucide-react"
<EmailField name="email" 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. Use them for textarea toolbars, helper rows, or 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 />, which shows a spinner in the
input while an async validator runs and renders nothing once it resolves. See
the
Async submission example
for a full setup.
import { ValidationSpinner } from "@zeno-lib/forms/create-form"
<EmailField
name="email"
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 useFieldContext() returns
that field's state.
Don't toggle children directly.
<EmailField>{cond && <Addon />}</EmailField>swaps the field between a plain<Input>and an<InputGroup>wrapper, which remounts the input, so it loses focus. Always render the child element, and let it returnnullwhen it has nothing to show, asValidationSpinnerdoes.
Select field
SelectField wraps @zeno-lib/ui/select. Pass <SelectItem> children for
the options. For database rows with numeric IDs, 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"
/>Checkbox group field
CheckboxGroupField binds a set of checkboxes to one array value ([] when
none is checked) and renders them as a <fieldset> with the label as its
legend. Pass items as strings or numbers, or as
{ value, label, description?, disabled? } objects. The stored array follows
the order of items, not click order.
<CheckboxGroupField
items={[
{ label: "Email", value: "email" },
{ label: "SMS", value: "sms" },
{ description: "Coming soon.", disabled: true, label: "Push", value: "push" },
]}
label="Notification channels"
name="channels"
/>orientation="horizontal" wraps the options in a row; disabled disables
the whole group.
Switch field
<SwitchField
description="Get notified when something needs your attention."
label="Push notifications"
name="pushNotifications"
/>Radio group field
RadioGroupField 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/create-form"
<RadioGroupField label="Plan" name="plan">
<RadioGroupFieldItem value="free">Free</RadioGroupFieldItem>
<RadioGroupFieldItem value="pro">Pro</RadioGroupFieldItem>
<RadioGroupFieldItem value="team">Team</RadioGroupFieldItem>
</RadioGroupField>Each item's value is stored as is, so value={true} or value={2} gives a
boolean or a number, not a string. Nothing picked is undefined.
Slider field
SliderField wraps @zeno-lib/ui/slider. The form value is a number for a
single thumb and a number[] for a range; the type of your defaultValues
picks which. 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
OtpField wraps @zeno-lib/ui/input-otp and defaults to one group of 6
slots. Change the count 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
DatePickerField composes @zeno-lib/ui/popover and @zeno-lib/ui/calendar.
The form value is Date | undefined. By default the trigger formats the
selected date with toLocaleDateString. Pass formatValue to override it, or
calendarProps to forward options (disabled, locale, …) to the
underlying <Calendar>. disabled on the field itself disables the trigger.
<DatePickerField
description="Used for age-restricted features."
label="Birthday"
name="birthday"
/>Combobox field
ComboboxField wraps @zeno-lib/ui/combobox. It selects a single value and
filters the options client-side 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 on an optional field and false on a required one. |
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. |
disabled | boolean | Disable the input and its buttons. |
For server-driven search with debouncing, see Async-search combobox.
With { value, label } items, the input shows a stored value only once
items holds its item, so an id never shows raw while options load.
shadcn's ComboboxClear is an icon with no accessible name. Give it one in
your copy of combobox.tsx (aria-label="Clear"), as Zeno's does.
Multi-select field
MultiSelectField is a multi-value combobox that renders the selection as
removable chips inside the input. The form value is an array of the selected
values ([] when empty). For string and number items it holds the items
themselves; for { value, label } objects it holds item.value, so numeric
IDs stay numbers. A clear button empties the selection.
const LANGUAGES = [
{ label: "English", value: "en" },
{ label: "French", value: "fr" },
{ label: "German", value: "de" },
]
<MultiSelectField
items={LANGUAGES}
label="Languages"
name="languages"
placeholder="Search languages…"
/>| Prop | Type | What it does |
|---|---|---|
items | T[] (string, number or { value, label }) | Options, filtered client-side as the user types. |
renderItem | (item: T) => ReactNode | Override per-row rendering. |
emptyMessage | ReactNode | Shown when filtering produces zero matches. |
placeholder | string | Shown while nothing is selected. |
showClear | boolean | Show the clear-all button. Defaults to true. |
clearLabel | string | Accessible label of the clear button. Defaults to "Clear". |
disabled | boolean | Disable the whole control. |
For grouped options or on-the-fly creation, compose your own; see Combobox: multi-select, grouped, creatable.
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 the shipped fields use, and useIsInvalid applies
their rules for when an error shows.
"use client"
import { useFieldContext, useIsInvalid } from "@zeno-lib/forms/create-form"
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>Use AppField too when you want direct access to a shipped field's
instance.