Listbox
A list of selectable options with no popover of its own.
Usage
import {
createListCollection,
Listbox,
ListboxContent,
ListboxItem,
ListboxItemIndicator,
ListboxItemText,
ListboxLabel,
} from "@kanzo-tech/ui";const roles = createListCollection({
items: [
{ label: "Warden", value: "warden" },
{ label: "Scout", value: "scout" },
],
});This is Select's list without Select's popover, and that is the whole
point: you place it. Inline in a panel, inside a Popover, inside a Sheet — the component
owns keyboard, selection and ARIA, and owns no positioning at all. It renders no portal, no
positioner and no trigger.
Listbox takes the same collection as Select and Combobox, so options are declared once and
createListCollection reads label and value off each item. Build it at module scope or memoise
it — a new collection on every render resets the machine.
Reach for Select first. It is this listbox plus a trigger and a popover, wired, and it
submits with a plain form. Drop to Listbox when the list must live somewhere Select cannot put
it — which in this library means FacetFilter, where the same popover
also holds a footnote and a clear button.
Listbox holds a value, not commands — it is not a Menu, and the
one-line test for telling them apart is on
Controls.
Anatomy
Listbox
├── ListboxLabel the accessible name; there is no trigger to borrow one from
├── ListboxValueText
├── ListboxInput a filter field over the list; you do the filtering
└── ListboxContent
├── ListboxItemGroup
│ ├── ListboxItemGroupLabel
│ └── ListboxItem
│ ├── ListboxItemText
│ ├── ListboxItemIndicator
│ └── ListboxShortcut optional; a keyboard hint at the end of the row
└── ListboxEmptyListboxShortcut is exported for a keyboard hint at the end of a row. It is MenuShortcut under a
different data-slot, the same way CommandShortcut is, so the three are interchangeable in
appearance and differ only in what a recipe can select.
ListboxLabel matters more here than it looks. A Select gets its accessible name from its
trigger; a standalone listbox has no trigger, so without a label it is an unnamed list to a screen
reader. Ark points the content's aria-labelledby at it.
Multiple selection
selectionMode="multiple" and every click accumulates — no modifier key, because a pointer user
should not have to know about Cmd. "extended" is the file-manager behaviour instead
(click replaces, Shift extends); pass highlightOnHover={false} with it, since there
the highlight doubles as the shift-click anchor.
Searchable
A list long enough to need a filter field does not need a Combobox nested inside whatever
surface it already lives in — that is two machines sharing one open-state and one positioner, and
they fight. ListboxInput is the Ark part for it, and it goes inside Listbox, above
ListboxContent.
The machine filters nothing. You do. @zag-js/listbox holds no input value — there is no
inputValue on its context and no onInputValueChange on its props. ListboxInput is an
uncontrolled <input> whose whole job is ARIA (aria-controls, aria-activedescendant,
aria-autocomplete="list") plus forwarding keys to the content: ↑/↓ always,
Home/End and ←/→ only under
keyboardPriority="navigate", and Enter as a click on the highlighted row. Narrowing
the collection is yours — the same contract as Combobox, and rather more
so, since there the machine at least holds the query.
Filter with useFilter rather than toLowerCase().includes(): it collates,
so miren matches Mirén Costa. Hoist its options object — useFilter memoises on it by identity, so
an inline literal returns a fresh contains every render and rebuilds the collection with it.
Build the collection from what you draw. Ark navigates the collection, so an item filtered out of the DOM but left in the collection is a row the arrow keys highlight and nobody can see.
Groups
In a popover
The composition Select would have made for you, made by hand — worth seeing once, because it is
what FacetFilter is, and it is the reason ListboxContent declares
no height: scrolling belongs to whoever owns the surface.
Keyboard
| Key | Does |
|---|---|
| ↓ / ↑ | Moves the highlight to the next / previous option, stopping at the ends unless loopFocus. |
| ← / → | The same, in a horizontal listbox — ignored in the vertical default. |
| Home / End | Highlights the first / last option. |
| Enter | Selects the highlighted option — replacing the value under "single" and "extended", toggling it under "multiple". |
| Space | The same, unless a typeahead is in progress, where it is one more character. |
| A–Z | Typeahead: highlights the next option whose text starts with what you have typed. |
| Shift+↓ / ↑ / Home / End | Extends the selection from the anchor, under "multiple" and "extended" only. |
| ⌘/Ctrl+A | Selects every option, under "multiple" and "extended"; disallowSelectAll turns it off. |
| Esc | Clears the selection, when deselectable is set. |
With ListboxInput the field keeps the caret keys and the letters, so typeahead is gone and
Home/End only reach the list under keyboardPriority="navigate".
API Reference
Listbox
| Prop | Type | Default |
|---|---|---|
collection | ListCollection | — |
selectionMode | "single" | "multiple" | "extended" | "single" |
deselectable | boolean | false |
orientation | "vertical" | "horizontal" | "vertical" |
Everything else passes through to Listbox.Root — value / defaultValue (always a string[]),
onValueChange, disabled, loopFocus.
deselectable does the opposite of what upstream's own JSDoc says. Zag documents it as
"whether to disallow empty selection"; the machine does selection.deselectable = !!prop(...), and
the selection manager only unticks an already-selected value in single mode when it is true. So
deselectable allows emptying the selection, and it is off by default — without it, a
single-select listbox cannot be returned to "nothing chosen" by clicking the chosen row again. It
changes nothing in multiple mode, where toggling always unticks. Pinned by a test, because this is
the kind of thing a version bump reverses silently.
ListboxInput
| Prop | Type | Default |
|---|---|---|
size | "sm" | "md" | "lg" | "md" |
autoHighlight | boolean | false |
keyboardPriority | "caret" | "navigate" | "caret" |
Styled from the same inputVariants as Input, so a searchable listbox and a
Combobox are the same box. Everything else passes through to the <input> — value, onChange,
placeholder, aria-label. It has no label part of its own; name it, or ListboxLabel names the
list and the field goes unnamed.
autoHighlight re-aims the highlight at the collection's first row every time the collection
changes, which for a filter field means the top match sits under Enter after every
keystroke. Without it nothing is highlighted until you press ↓. keyboardPriority decides
who owns Home/End: "caret" leaves them to the text you are editing, which is
what a filter field wants.
A part Shark's listbox file omits. Added because a menu-shaped filter has nowhere to grow a search
field, and the alternative — a Combobox inside the Popover a Listbox already sits in — is two
machines fighting over one open-state. ListboxLabel is the same kind of addition.
ListboxItem
| Prop | Type | Default |
|---|---|---|
item | CollectionItem | — |
variant | "default" | "destructive" | "default" |
highlightOnHover | boolean | true |
ListboxItemGroup
| Prop | Type | Default |
|---|---|---|
heading | string | React.ReactNode | — |
Items borrow SelectItem's geometry rather than Shark's own listbox look, so a listbox rendered
beside a Select — or inside the same popover — agrees with it about row height, indent and where
the check lives. The end gutter that reserves room for the indicator is conditional, so an item
with no ListboxItemIndicator carries no dead space.