Selection Fields
Select, Combobox, RadioGroup, Checkbox, and more
Full example
The complete sandbox example (all select-family fields on one page), read directly from the
form-develop-app / form-develop-app-shadcn source at build time — switch skins to see the
same fields in both implementations.
'use client'
import { Box, Heading, Text, VStack } from '@chakra-ui/react'
import { Form } from '@letar/forms'
import { useState } from 'react'
import { z } from 'zod/v4'
import { DemoPageLayout, SubmittedDataPreview } from '../_components'
/**
* Демо-схема для NativeSelect и CascadingSelect
*/
const DemoSchema = z
.object({
// NativeSelect fields
simpleSelect: z.string().meta({
ui: { title: 'Simple NativeSelect', placeholder: 'Select option...' },
}),
sizeSelect: z.string().meta({
ui: { title: 'Size Selection' },
}),
prioritySelect: z
.string()
.optional()
.meta({
ui: { title: 'Priority (optional)' },
}),
// CascadingSelect fields
country: z.string().meta({
ui: { title: 'Country', placeholder: 'Select country...' },
}),
city: z
.string()
.optional()
.meta({
ui: { title: 'City', placeholder: 'Select city...' },
}),
// Вложенные поля
address: z.object({
region: z.string().meta({
ui: { title: 'Region', placeholder: 'Select region...' },
}),
district: z
.string()
.optional()
.meta({
ui: { title: 'District', placeholder: 'Select district...' },
}),
}),
// Group.Field.Select с getGroup (optgroup)
technology: z
.string()
.optional()
.meta({
ui: { title: 'Technology (grouped)', placeholder: 'Select technology...' },
}),
})
.strip()
type DemoData = z.infer<typeof DemoSchema>
const initialData: DemoData = {
simpleSelect: '',
sizeSelect: 'md',
prioritySelect: undefined,
country: '',
city: undefined,
address: {
region: '',
district: undefined,
},
technology: undefined,
}
// Данные для NativeSelect
const simpleOptions = [
{ title: 'Option 1', value: 'opt1' },
{ title: 'Option 2', value: 'opt2' },
{ title: 'Option 3', value: 'opt3' },
]
const sizeOptions = [
{ title: 'Extra Small', value: 'xs' },
{ title: 'Small', value: 'sm' },
{ title: 'Medium', value: 'md' },
{ title: 'Large', value: 'lg' },
]
const priorityOptions = [
{ title: 'Low Priority', value: 'low' },
{ title: 'Medium Priority', value: 'medium' },
{ title: 'High Priority', value: 'high' },
{ title: 'Critical', value: 'critical' },
]
// Данные для CascadingSelect
const countries = [
{ label: 'Russia', value: 'ru' },
{ label: 'USA', value: 'us' },
{ label: 'Germany', value: 'de' },
]
const citiesByCountry: Record<string, { label: string; value: string }[]> = {
ru: [
{ label: 'Moscow', value: 'msk' },
{ label: 'Saint Petersburg', value: 'spb' },
{ label: 'Novosibirsk', value: 'nsk' },
],
us: [
{ label: 'New York', value: 'nyc' },
{ label: 'Los Angeles', value: 'la' },
{ label: 'Chicago', value: 'chi' },
],
de: [
{ label: 'Berlin', value: 'ber' },
{ label: 'Munich', value: 'mun' },
{ label: 'Hamburg', value: 'ham' },
],
}
const regions = [
{ label: 'Moscow Region', value: 'msk_reg' },
{ label: 'Leningrad Region', value: 'len_reg' },
{ label: 'Krasnodar Region', value: 'krd_reg' },
]
// Данные для группированного Select (getGroup)
const technologies = [
{ label: 'React', value: 'react', category: 'Frontend' },
{ label: 'Vue', value: 'vue', category: 'Frontend' },
{ label: 'Svelte', value: 'svelte', category: 'Frontend' },
{ label: 'Express', value: 'express', category: 'Backend' },
{ label: 'NestJS', value: 'nestjs', category: 'Backend' },
{ label: 'React Native', value: 'react-native', category: 'Mobile' },
]
const districtsByRegion: Record<string, { label: string; value: string }[]> = {
msk_reg: [
{ label: 'Odintsovo', value: 'odin' },
{ label: 'Khimki', value: 'khim' },
{ label: 'Balashikha', value: 'bal' },
],
len_reg: [
{ label: 'Vsevolozhsk', value: 'vsev' },
{ label: 'Gatchina', value: 'gat' },
{ label: 'Vyborg', value: 'vyb' },
],
krd_reg: [
{ label: 'Sochi', value: 'soc' },
{ label: 'Novorossiysk', value: 'nov' },
{ label: 'Anapa', value: 'ana' },
],
}
export default function SelectDemoPage() {
const [submitted, setSubmitted] = useState<DemoData | null>(null)
return (
<DemoPageLayout title="Select Demo" description="NativeSelect и CascadingSelect компоненты" maxW="800px">
<Form
schema={DemoSchema}
initialValue={initialData}
onSubmit={(data) => {
setSubmitted(data)
}}
>
{/* NativeSelect секция */}
<Box borderWidth={1} borderRadius="md" p={4} mb={6}>
<Heading size="md" mb={4}>
NativeSelect
</Heading>
<Text color="fg.muted" mb={4}>
Нативный браузерный select для лучшего UX на мобильных устройствах
</Text>
<VStack gap={4} align="stretch">
<Form.Field.NativeSelect name="simpleSelect" options={simpleOptions} />
<Form.Field.NativeSelect name="sizeSelect" options={sizeOptions} />
<Form.Field.NativeSelect name="prioritySelect" options={priorityOptions} />
</VStack>
</Box>
{/* CascadingSelect секция */}
<Box borderWidth={1} borderRadius="md" p={4} mb={6}>
<Heading size="md" mb={4}>
CascadingSelect
</Heading>
<Text color="fg.muted" mb={4}>
Каскадный select с зависимостью от другого поля (Страна → Город)
</Text>
<VStack gap={4} align="stretch">
<Form.Field.Select name="country" options={countries} />
<Form.Field.CascadingSelect
name="city"
dependsOn="country"
loadOptions={async (parentValue) => {
// Имитация загрузки с сервера
await new Promise((r) => setTimeout(r, 300))
const countryCode = parentValue as string | undefined
if (!countryCode) {
return []
}
return citiesByCountry[countryCode] ?? []
}}
/>
</VStack>
</Box>
{/* Вложенные CascadingSelect */}
<Box borderWidth={1} borderRadius="md" p={4} mb={6}>
<Heading size="md" mb={4}>
Nested CascadingSelect
</Heading>
<Text color="fg.muted" mb={4}>
Каскадные select с вложенными путями (address.region → address.district)
</Text>
<VStack gap={4} align="stretch">
<Form.Field.Select name="address.region" options={regions} />
<Form.Field.CascadingSelect
name="address.district"
dependsOn="address.region"
loadOptions={async (parentValue) => {
await new Promise((r) => setTimeout(r, 200))
const regionCode = parentValue as string | undefined
if (!regionCode) {
return []
}
return districtsByRegion[regionCode] ?? []
}}
/>
</VStack>
</Box>
{/* Группированный Select (getGroup) */}
<Box borderWidth={1} borderRadius="md" p={4} mb={6}>
<Heading size="md" mb={4}>
Grouped Select (getGroup)
</Heading>
<Text color="fg.muted" mb={4}>
Опции сгруппированы по категории (optgroup) — симметрично группировке в Form.Field.Combobox
</Text>
<Form.Field.Select
name="technology"
options={technologies}
getGroup={(opt) => (opt as (typeof technologies)[number]).category}
/>
</Box>
<Form.Button.Submit>Submit</Form.Button.Submit>
</Form>
<SubmittedDataPreview data={submitted} />
</DemoPageLayout>
)
}'use client'
import { FieldCascadingSelect, FieldCombobox, FieldNativeSelect, FieldSelect } from '@letar/forms-shadcn'
import { useState } from 'react'
import { DemoForm, DemoPageLayout, SubmittedDataPreview } from '../_components'
const frameworkOptions = [
{ label: 'React', value: 'react' },
{ label: 'Vue', value: 'vue' },
{ label: 'Svelte', value: 'svelte' },
]
const shippingCountryOptions = [
{ label: 'Россия', value: 'ru' },
{ label: 'Казахстан', value: 'kz' },
]
const CITIES_BY_COUNTRY: Record<string, { label: string; value: string }[]> = {
ru: [{ label: 'Москва', value: 'msk' }, { label: 'Казань', value: 'kzn' }],
kz: [{ label: 'Алматы', value: 'alm' }, { label: 'Астана', value: 'ast' }],
}
/** Имитация окна создания записи в приложении: задержка вместо диалога и серверного действия */
function fakeCreateDialog(name: string): Promise<{ label: string; value: string }> {
return new Promise((resolve) => {
setTimeout(() => {
resolve({ label: name.trim() || 'Новая запись', value: `new-${Math.random().toString(36).slice(2, 8)}` })
}, 400)
})
}
interface SelectValues {
framework: string
country: string
frameworkSearch: string
shippingCountry: string
shippingCity: string
createSelect: string
createCombobox: string
}
const defaultValues: SelectValues = {
framework: '',
country: '',
frameworkSearch: '',
shippingCountry: '',
shippingCity: '',
createSelect: '',
createCombobox: '',
}
export default function SelectDemoPage() {
const [submitted, setSubmitted] = useState<SelectValues | null>(null)
return (
<DemoPageLayout
title="Select-поля"
description="Select, NativeSelect, Combobox, CascadingSelect (зависимый select)"
>
<DemoForm<SelectValues> defaultValues={defaultValues} onSubmit={setSubmitted}>
<FieldSelect name="framework" label="Фреймворк" options={frameworkOptions} placeholder="Выберите" />
<FieldNativeSelect
name="country"
label="Страна"
options={[
{ label: 'Россия', value: 'ru' },
{ label: 'Казахстан', value: 'kz' },
]}
/>
<FieldCombobox name="frameworkSearch" label="Поиск фреймворка" options={frameworkOptions} />
<FieldSelect
name="shippingCountry"
label="Страна доставки"
options={shippingCountryOptions}
placeholder="Выберите"
/>
<FieldCascadingSelect
name="shippingCity"
label="Город доставки"
dependsOn="shippingCountry"
loadOptions={async (country) => CITIES_BY_COUNTRY[country ?? ''] ?? []}
placeholderWhenDisabled="Сначала выберите страну"
/>
{/* onCreate: создание записи справочника из поля (только статические опции) */}
<FieldSelect
name="createSelect"
label="Фреймворк (с пунктом «Добавить…»)"
options={frameworkOptions}
placeholder="Выберите"
createLabel="Добавить фреймворк…"
onCreate={async () => fakeCreateDialog('Новый фреймворк')}
/>
<FieldCombobox
name="createCombobox"
label="Поиск фреймворка (с созданием)"
options={frameworkOptions}
onCreate={async (text) => {
// Путь «пользователь закрыл окно»: null — ничего не меняется
if (text.trim().toLowerCase().startsWith('отмена')) {
return null
}
return fakeCreateDialog(text)
}}
/>
<button
type="submit"
className="bg-primary text-primary-foreground rounded-md px-4 py-2 text-sm font-medium"
>
Отправить
</button>
</DemoForm>
<SubmittedDataPreview data={submitted} />
</DemoPageLayout>
)
}import { AppForm } from '@letar/forms-vue'
import { defineComponent, h, ref } from 'vue'
import { z } from 'zod'
import { FieldSelect } from '../../src/index'
/**
* Изолированный пример FieldSelect — самодостаточный файл (своя Zod-схема, свой AppForm).
*/
const schema = z.object({
category: z.string().meta({ ui: { title: 'Категория' } }),
})
const CATEGORY_OPTIONS = [
{ value: 'furniture', label: 'Мебель' },
{ value: 'electronics', label: 'Электроника' },
{ value: 'books', label: 'Книги' },
]
export const SelectDemo = defineComponent({
name: 'SelectDemo',
setup() {
const submitted = ref<Record<string, unknown> | null>(null)
return () =>
h('div', { class: 'space-y-6' }, [
h(
AppForm,
{
schema,
initialValue: { category: '' },
onSubmit: (value: Record<string, unknown>) => {
submitted.value = value
},
},
{
default: () => [
h(FieldSelect, { name: 'category', options: CATEGORY_OPTIONS }),
h(
'button',
{ type: 'submit', class: 'bg-primary text-primary-foreground rounded-md px-4 py-2 text-sm' },
'Сохранить',
),
],
},
),
submitted.value
? h('pre', { class: 'bg-muted mt-4 rounded-md p-3 text-xs' }, JSON.stringify(submitted.value, null, 2))
: null,
])
},
})Angular-пример для этого поля появится позже.
Select
API: Form.Field.Select
Dropdown select for choosing from a list of options.
const Schema = z.object({
category: z.enum(['electronics', 'clothing', 'books']).meta({
ui: { title: 'Category' },
}),
})
<Form.Field.Select name="category" />Options are automatically generated from z.enum(). For custom labels:
const Schema = z.object({
status: z.enum(['active', 'inactive', 'pending']).meta({
ui: {
title: 'Status',
options: [
{ value: 'active', label: 'Active' },
{ value: 'inactive', label: 'Inactive' },
{ value: 'pending', label: 'Pending Review' },
],
},
}),
})Search inside Select (searchable)
API: searchable, renderEmpty on Form.Field.Select (Chakra skin)
From the 10th option the open list of a Select shows a search field with autofocus — no prop needed.
<Form.Field.Select name="region" options={regions} /> {/* 10+ options: search appears */}
<Form.Field.Select name="region" options={regions} searchable={false} /> {/* off */}
<Form.Field.Select name="region" options={few} searchable /> {/* always */}
<Form.Field.Select
name="region"
options={regions}
searchable={{ threshold: 20, placeholder: 'Find a region', emptyMessage: 'No such region' }}
/>- Matches the option text without case and diacritics,
ё≡е, and also a query typed in the wrong keyboard layout ("ghbdtn" finds "Привет"). The selected value stays in the button while the list is filtered. - The first matching option is highlighted: Enter picks it, arrows move, Escape closes and clears the query.
- With
onCreatethe query goes to the handler:onCreate('typed text'); the «+ Add "…"» item comes after the filter.renderEmpty({ search })replaces the «nothing found» text. - ⚠️ Behaviour change: in a
Selectwith 10+ options, typing after opening now filters the list instead of jumping by typeahead. Turn it off withsearchable={false}. The shadcn skin (@letar/forms-shadcn0.51.0+) searches the same way: with search on, the list is drawn on a Popover instead of Radix Select; arrows, Enter, Escape and F2 work. - Select or Combobox? All options are already on the client (enum, a dictionary up to hundreds of records) —
Select. Options come from the server by the search text, or the catalog keeps growing —ComboboxwithuseQuery.
Loading and the selected record (loading, useSelected)
Select—loading={query.isLoading}: spinner in the field, «Loading…» in the empty list and, while the selected value has no option yet, in the button instead of the placeholder.Combobox—useSelected={(id) => useFindUniqueCategory({ where: { id } }, { enabled: !!id })}loads the record of the current value when it is not in the search page: the input shows its label withoutinitialLabel, the pencil and F2 work,onUpdatereceivesdata.RelationFieldProvider: the whole record reachesoption.data;RelationConfig.fieldPropsare shared props for every field of the model (the field's ownfieldPropswin).
Data sources (loadOptions, loadSelected, @letar/forms-query)
Combobox takes exactly one source of options (the second one does not pass the types): static options
(+ loading), the hook path useQuery (+ useSelected) or the promise path loadOptions
(+ loadSelected) — a server action, fetch, an SDK, no TanStack Query needed. getLabel/getValue are required for
both async paths.
<Form.Field.Combobox
name="userId"
loadOptions={(search, { signal }) => searchUsers({ search }, signal)}
loadSelected={(id, { signal }) => getUser({ id }, signal)}
getLabel={(u) => u.name}
getValue={(u) => u.id}
onLoadError={(error) => log(error)}
/>- The request starts after
debounceonceminCharsis reached (minChars: 0— with an empty string when the list is first opened; a list that was never opened sends nothing). - A new request cancels the previous one (
signal); only the last result is applied; the previous results stay on screen with a spinner. An error shows «Failed to load» and «Retry» (Enter works too), no automatic retries. - After a confirmed
onCreate/onUpdatethe current search is requested again. For a cache and invalidation by key useuseLoaderQueryfrom@letar/forms-query.
The @letar/forms-query package (both skins): fromSearchQuery, fromSelectedQuery, useQueryOptions,
useLoaderQuery, useInvalidateAfter, /zenstack → useInvalidateModels. @letar/forms does not depend on it.
Combobox
API: Form.Field.Combobox
Searchable select with autocomplete. Great for large option lists. All options already on the client? A plain Select is enough: its search field appears by itself from the 10th option.
const Schema = z.object({
country: z.string().meta({
ui: { title: 'Country', placeholder: 'Search countries...' },
}),
})
<Form.Field.Combobox
name="country"
options={countries.map(c => ({ value: c.code, label: c.name }))}
/>Async Search
Load options from a query hook as the user types via useQuery — signature matches
TanStack Query ({ data, isLoading, error }):
<Form.Field.Combobox
name="user"
useQuery={(search) =>
useFindManyUser({
where: { name: { contains: search, mode: 'insensitive' } },
take: 20,
})
}
getLabel={(u) => u.name}
getValue={(u) => u.id}
/>Editing an existing value with async search
With static options, the label for the current value is always available, so the field shows
it as soon as it mounts. With useQuery, the item matching the current value may not be in the
first (pre-search) result page — there's nothing to look the label up in yet. Pass initialLabel
explicitly when editing an entity with a pre-selected value, otherwise the field shows an empty
input even though the value is set:
<Form.Field.Combobox
name="userId"
useQuery={(search) =>
useFindManyUser({
where: { name: { contains: search, mode: 'insensitive' } },
take: 20,
})
}
getLabel={(u) => u.name}
getValue={(u) => u.id}
initialLabel={initialValues.userName}
/>Creating a record from the field (onCreate)
API: onCreate and createLabel on Form.Field.Select and Form.Field.Combobox
When the needed dictionary record does not exist yet, the user creates it right from the field without leaving the form. The library does not render the creation dialog: your app opens its own dialog (or calls its own Server Action) and returns the created record.
<Form.Field.Select
name="categoryId"
options={categories}
onCreate={async () => {
const created = await openCategoryDialog()
return created ? { label: created.name, value: created.id } : null
}}
/>
<Form.Field.Combobox
name="supplierId"
options={suppliers}
onCreate={async (name) => {
const created = await openSupplierDialog({ name })
return created ? { label: created.name, value: created.id } : null
}}
/>| Prop | Description |
|---|---|
onCreate | (search: string) => Promise<CreatedOption | null>, where CreatedOption is { label: string, value: string | number }. For Select search is empty, for Combobox it is the typed text |
createLabel | Text of the item after "+ ". The Chakra skin defaults to the FormI18nProvider string ("Add…" / "Добавить…") |
How the create item behaves
- Select. The last list item is "+ Add…". Picking it calls
onCreate(''). - Combobox. While the search text is non-empty and no option label equals it exactly
(case-insensitive), the list ends with a
+ Add "text"item. Picking it callsonCreate(text). Works with staticoptionsand withuseQuery(Chakra skin). - Result. A returned
{ label, value }is added to the list and selected. A returnednull(the user closed the dialog) changes nothing, and the Combobox keeps the search text. - Service value. The value of the create item never reaches the form data.
- Errors. An exception thrown by
onCreateis not swallowed: it surfaces as an unhandled rejection. Catch it inside the callback and show your own message.
Lifetime of the created record. It lives while the field is mounted. If the app's own
options later contain a record with the same value (for example after the dictionary was
revalidated), the app's option wins and there is no duplicate.
createForm wrappers. For extraSelects, lazySelects, extraComboboxes and
lazyComboboxes the app's wrapper component must forward onCreate to the inner field (spread
the props), otherwise the create item does not appear.
Skins. Implemented in Chakra (@letar/forms) and shadcn (@letar/forms-shadcn, static
options only; the default item text is "Добавить…"). Not implemented yet in
@letar/forms-vue, @letar/forms-vue-shadcn and @letar/forms-angular.
Custom option rendering (renderOption, renderValue, textValue, data)
API: renderOption, renderValue, textValue, data on Form.Field.Select;
renderOption, getTextValue, textValue, data on Form.Field.Combobox. Chakra and shadcn skins.
An option carries typed app data (data) and can be drawn as any node. TData is inferred from
options (for Combobox with useQuery — from the loaded items) and flows into the render functions.
<Form.Field.Select
name="cityId"
options={cities.map((c) => ({ value: c.id, label: c.name, data: c }))}
renderOption={(o, { selected }) => <span>{o.label} <small>{o.data?.region}</small></span>}
renderValue={(o) => <b>{o.data?.name}</b>}
/>| Prop / option field | Where | Description |
|---|---|---|
data | option | App data, never read by the field |
textValue | option | String form for search, typeahead and the trigger caption. Needed when label is not a string |
renderOption | Select, Combobox | (option, { selected, disabled }) => ReactNode; the skin keeps its item frame |
renderValue | Select only | (option) => ReactNode for the trigger; null/'' falls back to the option text |
getTextValue | Combobox only | (item) => string for useQuery items |
- A non-string
labelis no longer flattened to a string: the item draws the node. WithouttextValuesearch, typeahead and the trigger caption usevalue; in development the field warns once. renderValuelives inside a<button>— phrasing content only. While nothing is selected theplaceholdershows.- The service "+ Add…" item skips
renderOption;onCreatemay returndata. - Combobox has no
renderValue: the input holds plain text. - ⚠️ Behavior change: options with a node
labelused to show theirvalue, now they show the node.
Editing a record from the field (onUpdate)
API: onUpdate, createItem, listFooter, Form.Field.Select.EditButton, Form.Field.Select.CreateButton
on Form.Field.Select; the same plus renderEmpty and getEditable on Form.Field.Combobox. Chakra and shadcn skins.
A pencil sits at every item and at the selected value. The app opens its own dialog (its server action)
and returns { label, value, data? } — or null if the user cancelled.
<Form.Field.Select
name="workTypeId"
options={workTypes.map((w) => ({ value: w.id, label: w.name, data: w, editable: !w.system }))}
onUpdate={async (option) => {
const saved = await openWorkTypeDialog(option.data)
return saved ? { label: saved.name, value: saved.id, data: saved } : null
}}
/>- Same
value= caption fix (the form stays clean). Anothervalue= the record was replaced; the selected one follows it. - The edit lies over the app's
optionsuntil the app refetches the list. - The list closes before the app's dialog; while the action runs the pencils are disabled.
- F2 (Fn+F2 on laptops) edits the highlighted item or the selected value. The shadcn Combobox has no keyboard list navigation, so no F2 there.
editable: falseon an option hides its pencil. With your ownrenderOptionthe default pencil is not drawn — put<Form.Field.Select.EditButton />inside. Slots insiderenderValuedraw nothing (a<button>cannot nest a button).createItem={false}removes the service "+ Add…" item: use<Form.Field.Select.CreateButton />inlistFooter. Both slots acceptasChild.
Optimistic mode (ctx.optimistic)
onCreate(search, ctx) and onUpdate(option, ctx) get ctx.optimistic(preview) as the second argument. Call it when
the app dialog is closed and the request has gone to the server: the field shows and selects the result at once
instead of waiting.
<Form.Field.Select
name="categoryId"
{...categories.fieldProps}
onCreate={async (search, { optimistic }) => {
const input = await dialog.open({ name: search })
if (!input) { return null }
optimistic({ label: input.name }) // the record is visible and selected right away
const created = await create.mutateAsync({ data: input }) // the server's answer, not the input
return { label: created.name, value: created.id, data: created }
}}
onSettleError={(info) => toast.error(`Could not save “${info.preview.label}”`)}
/>- While the server is silent the record is dimmed with a spinner (
aria-busy); the temporary id never reaches the form value. A resolve replaces it with the real record;null, a rejection orsettleTimeout(30 s) roll it back. - Without
onSettleErrorthe field shows its own message under itself (role="status"). - The form's submit waits for pending actions: the submit button shows a spinner,
onSubmitgets the real value, a refusal cancels the submit. - An option with
pending: true(orgetPendingon Combobox) is dimmed, not selectable, without a pencil. For ZenStackoptimisticUpdate: trueuseuseZenStackOptionsfrom@letar/forms-query/zenstack.
RadioGroup
API: Form.Field.RadioGroup
Radio button group for single selection.
const Schema = z.object({
plan: z.enum(['free', 'pro', 'enterprise']).meta({
ui: { title: 'Plan' },
}),
})
<Form.Field.RadioGroup name="plan" />Checkbox
API: Form.Field.Checkbox
Standard checkbox for boolean values.
const Schema = z.object({
agree: z.boolean().meta({
ui: { title: 'I agree to the terms and conditions' },
}),
})
<Form.Field.Checkbox name="agree" />Switch
API: Form.Field.Switch
Toggle switch — visually distinct from checkbox.
const Schema = z.object({
notifications: z.boolean().meta({
ui: { title: 'Enable notifications' },
}),
})
<Form.Field.Switch name="notifications" />Tags
API: Form.Field.Tags
Tag input with autocomplete. Users can add multiple tags.
const Schema = z.object({
skills: z.array(z.string()).meta({
ui: { title: 'Skills', placeholder: 'Add a skill...' },
}),
})
<Form.Field.Tags name="skills" />
// With max limit
<Form.Field.Tags name="skills" maxTags={5} />RadioCard
API: Form.Field.RadioCard
Card-based selection — visually richer than RadioGroup. Supports description per option.
const options = [
{ value: 'starter', label: 'Starter', description: '$0/mo — for side projects' },
{ value: 'pro', label: 'Pro', description: '$29/mo — for teams' },
{ value: 'enterprise', label: 'Enterprise', description: '$99/mo — unlimited' },
]
<Form.Field.RadioCard name="plan" options={options} />CheckboxCard
API: Form.Field.CheckboxCard
Card-based multiple selection. Like RadioCard but allows selecting multiple items.
<Form.Field.CheckboxCard
name="features"
options={[
{ value: 'auth', label: 'Authentication', description: 'OAuth, magic links' },
{ value: 'payments', label: 'Payments', description: 'Stripe integration' },
{ value: 'analytics', label: 'Analytics', description: 'Usage tracking' },
]}
/>Listbox
API: Form.Field.Listbox
Dropdown list for single or multiple selection. Alternative to Select for larger lists.
// Single selection
<Form.Field.Listbox name="timezone" options={timezoneOptions} />
// Multiple selection
<Form.Field.Listbox name="permissions" options={permissionOptions} selectionMode="multiple" />
// Second line under the label — `description` on an option (Chakra and shadcn skins)
<Form.Field.Listbox name="plan" options={[{ value: 'pro', label: 'Pro', description: 'For teams' }]} />NativeSelect
API: Form.Field.NativeSelect
Standard HTML <select> element. Lighter than Select, uses native browser UI.
<Form.Field.NativeSelect name="country" options={countryOptions} />SegmentedGroup
API: Form.Field.SegmentedGroup
Segmented control — pill-shaped toggle group for small option sets.
<Form.Field.SegmentedGroup
name="view"
options={[
{ value: 'grid', label: 'Grid' },
{ value: 'list', label: 'List' },
{ value: 'table', label: 'Table' },
]}
/>