Kanzo UI
Forms

Listbox

A list of selectable options with no popover of its own.

Ark UI
Duty on this contract
Warden
Scout
Cantor
Sapper
Alchemist
Archivist

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
    └── ListboxEmpty

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

Availability
Ready
Afield
Resting
Wounded
Missing
readyresting

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.

Roster
Ravenna Sarkis
Piet Marrow
Oyelaran Bell
Inés Vault
Tomas Quill
Hesper Vane
Bram Tallow
Halla Grieve
Sabin Okonkwo
Mirén Costa
Otto Lindqvist
Yusra Halim
Nerea Solano
Tobias Creel
Corvin Delacroix
Baruch Stiles
Ludmila Vrána
Fenn Aldabra
Kesi Adeyemi
Ilse Danner
Ozren Vuk
Dagfinn Roe
Solveig Marsh
Rui Ferreiro
Nadia Kestrel
Emrys Coldwell
Perpetua Lund
Faisal Amari
Wren Halloway
Ansel Thibault
Beatrix Odemba
Silas Aughton
Doro Mikkelsen
Ágata Reyes
Cuthbert Lyle

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

Wyrm
Basilisk
Stoneback
Grimalkin
Bog-hound
Harpy
Revenant
Mimic

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

KeyDoes
/ 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 / EndHighlights the first / last option.
EnterSelects the highlighted option — replacing the value under "single" and "extended", toggling it under "multiple".
SpaceThe same, unless a typeahead is in progress, where it is one more character.
AZTypeahead: highlights the next option whose text starts with what you have typed.
Shift+ / / Home / EndExtends the selection from the anchor, under "multiple" and "extended" only.
/Ctrl+ASelects every option, under "multiple" and "extended"; disallowSelectAll turns it off.
EscClears 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

PropTypeDefault
collectionListCollection
selectionMode"single" | "multiple" | "extended""single"
deselectablebooleanfalse
orientation"vertical" | "horizontal""vertical"

Everything else passes through to Listbox.Rootvalue / 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

PropTypeDefault
size"sm" | "md" | "lg""md"
autoHighlightbooleanfalse
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

PropTypeDefault
itemCollectionItem
variant"default" | "destructive""default"
highlightOnHoverbooleantrue

ListboxItemGroup

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

On this page