ZenoZENO

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

Shown next to your avatar.

<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

160 characters max.

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

CHF

Try pasting 1.234,50 or CHF 12'000.

<MoneyField currency="CHF" label="Budget" locale="de-CH" name="budget" />
PropTypeWhat it does
currencystringISO 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).
localestringBCP 47 locale for the separators. Defaults to the runtime locale.
fractionDigitsnumberDecimals allowed. Defaults to the currency's minor units (2 for CHF, 0 for JPY), else 2.
allowNegativebooleanAccept a leading minus. Defaults to false.
min / maxnumberClamp 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, so 12.5 shows as 12.5 %.
  • scale="fraction": the value is a ratio, so 0.125 shows as 12.5 %, which is what most APIs and databases store.
%

Stored as a 0–1 ratio (scale='fraction').

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

Clamped to 1900–2100 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

https://
.zeno.dev
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 return null when it has nothing to show, as ValidationSpinner does.

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

You can revoke this at any time.

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

Notification channels

Where should we reach you?

Coming soon.

<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

Get notified when something needs your attention.

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

50%

Used for notification chimes.

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

We sent a code to your email.

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

Used for age-restricted features.

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

Filter as you type.

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…"
/>
PropTypeWhat it does
itemsT[] (string or { value, label })Options shown in the dropdown. Filtered client-side unless filter={null}.
renderItem(item: T) => ReactNodeOverride per-row rendering.
emptyMessageReactNodeShown when filtering produces zero matches.
showClearbooleanShow the clear button when a value is selected. Defaults to true on an optional field and false on a required one.
placeholderstringInput placeholder when the field is empty.
inputValuestringControlled search text. Pair with onInputValueChange.
onInputValueChange(value: string) => voidFires on every keystroke in the search input.
filter((item, query) => boolean) | nullReplace the built-in filter, or null for server-driven search.
loadingbooleanShow a spinner inside the input while options load.
disabledbooleanDisable 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.

Stored as an array of values.

const LANGUAGES = [
  { label: "English", value: "en" },
  { label: "French", value: "fr" },
  { label: "German", value: "de" },
]

<MultiSelectField
  items={LANGUAGES}
  label="Languages"
  name="languages"
  placeholder="Search languages…"
/>
PropTypeWhat it does
itemsT[] (string, number or { value, label })Options, filtered client-side as the user types.
renderItem(item: T) => ReactNodeOverride per-row rendering.
emptyMessageReactNodeShown when filtering produces zero matches.
placeholderstringShown while nothing is selected.
showClearbooleanShow the clear-all button. Defaults to true.
clearLabelstringAccessible label of the clear button. Defaults to "Clear".
disabledbooleanDisable 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.

Audio settings
Custom slider field built on top of `useFieldContext`.

Drag to set the master volume.

"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.