Zeno

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

Shown next to your avatar.

<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

160 characters max.

<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

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. 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 return null internally when not needed (the way ValidationSpinner does).

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

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

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

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.

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

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.

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

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

Used for age-restricted features.

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

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

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.

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