Controls
Every form control in the library, grouped by what it captures. A table index — start here to find the right one.
Every control goes inside a Field the same way, so there is no per-control API
to learn. This page is the index: find the control you need by what it captures, then follow the
link for its own page.
The naming rule that explains half of these. A kebab-case file is the vendored
primitive — the compound with all its parts exposed. A PascalCase one is our pre-assembled
convenience, built on it: FacetFilter imports listbox and popover, FieldArray imports
field. They are not competitors; one is made of the other. Default to the PascalCase one, and
drop to the primitive when you need to rearrange the parts. The rule in full is on
Philosophy.
One height per size
A single-line control is exactly as tall as its size token, and every one of them is. sm,
md and lg are 7, 8 and 9 times --size-field, so 28, 32 and 36px at the default density
and 24.5, 28 and 31.5px at compact — a text input, a number input, a native select, a select, a
combobox, a tags input, a date picker and its time field, a language picker, a password input, a
pin cell, a segment group, a toggle and a button all share it. Put any of them beside any other, in a
row or a column, and their edges line up.
The height is written once, in packages/ui/src/lib/control-size.ts: a controlSizes fragment
that assigns --size per token and a controlHeight that reads it. Every recipe spreads the first
into its size variant and puts the second on its base, and control-height.test.ts fails when
one stops, or when a size variant names a fixed height of its own. A new control gets the height by
importing the fragment; it cannot drift by writing h-8.
- A control that grows takes the height as its minimum.
TagsInputandInputGrouparemin-h-(--size): empty, or holding one tag, they are one input tall, and a second row of tags or aTextareainside a group grows them. ATextareaon its own keeps a four-line minimum, which is above the height on purpose. - A control inside a group follows the group. The
<input>in anInputGrouphas no height of its own — it stretches to the group's — and a button in an addon is 24px, which leaves a 32px group 4px of inset around it. - A labelled row is one control tall. A
Switch, aCheckboxor aRadioGroupItemwith its words is 32px at rest, the indicator centred in it, so it lines up with an input beside it and with the rows above and below. Alone — no children, a cell in a table — it is the indicator and nothing else, 16 to 20px, and the height is not asked of it. The row ismd; there is nosizeon these. - Not a box. A
Badgeis a status chip whose 20 to 26px is a smaller scale on purpose, and aSlideris a track. Neither is asked to be an input. - One measured exception. A button is 24 CSS pixels, not
1.5rem, because WCAG 2.5.8 states pixels and a rem is 21px at compact density. Ansmgroup holding one — aCombobox, aDatePicker— is therefore 26px at compact, not 24.5. Every other cell of the table is exact.
Pick a size in it: every control is laid out in a grid with items-start, so one that is not the
height of its neighbours makes its row taller and shows. Measured 2026-09-30 in Chromium, md, every row of the example: 32px
at default density, 28px at compact and 36px at comfortable.
What would reverse it: a control that needs a different height per part rather than per size —
which a custom property on the base cannot say — or a design that wants an xl field. Button has
an xl that the shared scale lacks, and it is the one size a field does not offer.
Text
| Control | What it is | Reach for it when |
|---|---|---|
Input | A single-line text input. | The everyday field. |
Input type="number" | The same input, numeric, with a decimal keypad on mobile. No steppers — see NumberInput for those. | A number typed straight in: a price, an amount. |
Textarea | Multi-line text. | A description, a note, free text. |
InputGroup | An input flanked by addons — a prefix, a unit, a button. | The value needs an affix or an inline action. |
Editable | Edit-in-place: a preview that swaps for an input on click. | The value is normally read, and editing it is the exception. |
Choice
| Control | What it is | Many | Reach for it when |
|---|---|---|---|
NativeSelect | The browser's own <select> element, styled to the input scale. | no — it is the OS picker | Few options and plain text: it costs no JavaScript, and on mobile it opens the platform picker. |
Select | A listbox in a popover, driven by a collection. | multiple | The options need to look like more than plain text: icons, descriptions, groups. |
Listbox | That same listbox with no popover of its own — you place it. | selectionMode | The list must live somewhere Select cannot put it: inline in a panel, or in a popover you own. |
Combobox | A text input plus that same listbox — typing filters the collection. | multiple | The list is long enough that typing beats scrolling. |
LanguagePicker | A Combobox over BCP 47 tags, each named in its own language; closed when given a list, open otherwise. | no | The value is a language tag. |
FacetFilter | A Listbox in a popover behind a trigger that counts what is picked. | multiple | Narrowing a table or a dashboard, where the options are data and carry counts. |
TagsInput | A list of removable tokens with no option collection — the user invents the values. | always | The values do not exist beforehand: free-form tags, keywords, addresses. |
RadioGroup | A few options, all visible at once. | no | One of a few, no popover. |
RadioGroupCard | That same item styled as a card, with room for an icon, a description or a preview. | no | One of a few, where the options need explaining. |
SegmentGroup | 2–4 options as a compact inline switch. Radio semantics, segmented look. | no | Switching a view in place. |
Rule of thumb: under ~7 options, NativeSelect; over ~15, Combobox, because that is where
scrolling starts costing more than typing; Select in between, when the options need to look
like more than plain text. Multiple selection is not the deciding axis — Select and
Combobox both take multiple, and TagsInput is multiple by nature. Typing is.
Autocomplete is not a control here. It is the presentation of a Combobox with no trigger
button — showTrigger={false} — so it opens on type rather than on click. Same machine, same
props, one page: Combobox. A further face of that same
machine, Command, is not a form control at all: what it captures is an
action, so it is filed under actions.
A menu is a command; a listbox is a value
Half the "which control?" questions here are one question wearing several hats, and the answer is
an ARIA role, not a look. Two surfaces open a popover over a list, and they are not
interchangeable. role="menu"
(Menu, MenuItem, MenuCheckboxItem) is a list of commands: you
press one, something happens, the surface closes. It is not anybody's data. role="listbox"
(inside Select and Combobox) is a value: it
lives in state, and the popover is only its editor.
The test, in one line: if closing the surface leaves state, it is a listbox; if it leaves only an effect, it is a menu.
That is the only axis that matters. The rest of the Choice table is two orthogonal questions on top of it — can you type to filter? and how many can you pick? — which is why these controls look alike and are not.
Command is the honest exception, and reading it clarifies the rule
rather than breaking it: a palette runs commands yet is built on a combobox, because the role
follows the interaction, not the payload. Type to filter, arrow to highlight, enter to run is
combobox behaviour, and a menu cannot filter at all. So: a value needs a listbox; a command needs
a menu unless it needs to be searched.
The rule bites hardest on filters, because a filter is a value while the
popover-with-checkboxes idiom looks like a menu. Getting the role wrong costs more than purity: a
menuitemcheckbox list cannot announce "2 of 5 selected", and a menu has nowhere to grow a search
field the day the list has 200 values.
Boolean
| Control | What it is | Reach for it when |
|---|---|---|
Switch | Reads as on/off. | A setting that takes effect immediately. |
Checkbox | Reads as included/excluded, with a third indeterminate state. | A value submitted with the form. |
CheckboxGroup | One value array across several checkboxes. | Related checkboxes you submit together. |
Value
| Control | What it is | Reach for it when |
|---|---|---|
NumberInput | A spinbutton: stepper triggers, arrow keys, drag-to-scrub. | A bounded quantity the user nudges — a count, a page size. |
Slider | A value or range on a track. | A bounded number where the range matters more than the digit. |
Rating | A row of stars on a fixed scale. | A score out of five. |
DatePicker | A date input with a calendar popover. Takes Ark's DateValue; the ISO-string adapter is two functions at your seam. | Picking a date in a form. |
Calendar | The same machine inline, so the month grid is always visible. | A calendar that is furniture, not a form value. |
ColorPicker | Area, hue and hex, with preset swatches. | Picking a colour. |
A row of colours the user is not editing is not a control at all: when the colour merely
pictures a value that has a name, reach for Swatch, which is
aria-hidden and never focusable.
Files
| Control | What it is | Reach for it when |
|---|---|---|
FileUpload | A dropzone plus the list of what was picked, with previews. | The value is a file, not text. |
Credentials
| Control | What it is | Reach for it when |
|---|---|---|
PasswordInput | A password input with a reveal toggle. | Entering a password — sign-in, sign-up. |
PasswordInput + hasStoredValue | The same input in stored-credential mode: renders empty when a secret exists, and submitting empty means "keep it". | Editing a credential that already lives server-side. |
PinInput | One cell per character; a pasted code spreads across them. | A one-time password or verification code. |
Repeating
| Control | What it is | Reach for it when |
|---|---|---|
FieldArray | N rows with add and remove, stateless — the caller owns the values. | A field the user can have several of. |
FieldArray's rowKey must be stable across a commit. If a row's key changes when its
value is first saved, React remounts the input and the user loses focus mid-typing — do not
"simplify" it to the array index.
AI-assisted
Not a control of their own: Assist, from @kanzo-tech/ai, wraps one of the
controls above and lets a model propose its value. What it offers is decided by the control.
| Wrapped control | What the model offers | Reach for it when |
|---|---|---|
Textarea | A continuation at the caret, as ghost text — Tab to accept. | The user is writing prose and a streamed continuation helps. |
Input | Whole values, as chips under the field; one replaces the value. | The user is choosing a value and needs options they did not think of. |
TagsInput | Items, as chips; one is added. | The user is filing something and the model can see what fits. |
Every one of these sets its state on the Field, never on the control — see
Building a form, then Validation for
how errors are shown.
RadioGroupProps is exported, so a wrapper can take the same props without restating them.