Listbox
An accessible list of options you select from — single, multiple, or no selection, with roving or active-descendant focus, type-ahead, grouping, native form submission, and optional virtualization for very long lists.Import
import { Listbox } from "@hope-ui/components/listbox";Listbox is a compound component — a namespace object whose parts you compose yourself. See the
Anatomy below for every part and how they nest.
Usage
Listbox.Root owns the state (selection, focus, keyboard navigation, type-ahead, ARIA) and renders
the role="listbox" element. Each Listbox.Item is an option that self-registers, and
Listbox.ItemIndicator paints a check on the selected row. Map each item to a stable string with
itemToValue (the selection identity) and to its display / type-ahead text with itemToLabel.
const [value, setValue] = createSignal<Fruit[]>([]);
<Listbox.Root
aria-label="Choose a fruit"
itemToValue={(fruit) => String(fruit.id)}
itemToLabel={(fruit) => fruit.name}
value={value()}
onChange={setValue}
>
<For each={fruits}>
{(fruit) => (
<Listbox.Item value={fruit}>
{fruit.name}
<Listbox.ItemIndicator />
</Listbox.Item>
)}
</For>
</Listbox.Root>;root slot carries no popup chrome (no background, border, shadow, or padding), so a bare list sits in the page flow — that's exactly what the demos on this page show. A floating consumer (a Select / popover, or the future Combobox) is what layers the elevated surface on top; you can do the same yourself with a class (see Theming). And because Listbox reads a recipe, it needs a <ThemeProvider> ancestor fed a preset, as every styled hope-ui component does.Anatomy
Listbox.Root renders the list element; Listbox.Item is one option (with an optional
Listbox.ItemIndicator check inside). Listbox.Group + Listbox.GroupLabel name a section, and
Listbox.Separator divides sections — all collection mode only.
| Part | Element | Description |
|---|---|---|
Listbox.Root | div[role=listbox] | State owner and the list element (the scroll container in virtual mode). Owns selection, focus, keyboard, type-ahead, ids, and the resolved recipe. |
Listbox.Item | div[role=option] | One option. Self-registers and owns aria-selected, the active/disabled data attributes, and the click/pointer handlers. Give it value (collection) or index (virtual). |
Listbox.ItemIndicator | span | The selection check, shown only while its row is selected. Defaults to a built-in check; pass children for a custom glyph. |
Listbox.Group | div[role=group] | A labelled section, named by its GroupLabel via aria-labelledby. Collection mode only. |
Listbox.GroupLabel | div | Names its Group. Self-registers its id as the group's aria-labelledby. Collection mode only. |
Listbox.Separator | div[role=presentation] | A decorative hairline between sections (aria-hidden — never reported as an option). Collection mode only. |
Selection modes
selectionMode controls how many options can be chosen at once:
single(default) — one option; choosing another replaces it. (shown in Usage above)multiple— a set; Space or a click toggles each.Shift+Arrow extends the selection and⌘/Ctrl+Aselects all.none— nothing is ever selected; arrows and type-ahead still move the highlight — a browsing or command list.
Selection is an array in every mode (onChange receives V[]), and the value items are your own
objects — Listbox never forces you to key by string.
<Listbox.Root selectionMode="multiple" value={value()} onChange={setValue}>
{/* … */}
</Listbox.Root><Listbox.Root selectionMode="none">{/* … */}</Listbox.Root>Groups and separators
Wrap sections in a Listbox.Group with a Listbox.GroupLabel, and divide them with a
Listbox.Separator. The group is a role="group" named by its label (aria-labelledby); the
separator is decorative (role="presentation"). Keyboard navigation flows across groups as one list
— arrows skip the labels and the separator. Grouping is collection mode only — a virtual listbox
is flat.
<Listbox.Root aria-label="Choose a fruit" itemToValue={itemToValue} itemToLabel={itemToLabel}>
<Listbox.Group>
<Listbox.GroupLabel>Citrus</Listbox.GroupLabel>
<For each={citrus}>
{(fruit) => <FruitItem fruit={fruit} />}
</For>
</Listbox.Group>
<Listbox.Separator />
<Listbox.Group>
<Listbox.GroupLabel>Berries</Listbox.GroupLabel>
<For each={berries}>
{(fruit) => <FruitItem fruit={fruit} />}
</For>
</Listbox.Group>
</Listbox.Root>Sizes
The size prop scales density — the row text, padding, gap, and the panel's min width — across sm,
md (the default), and lg. It carries no color axis; Listbox is a neutral collection surface.
<Listbox.Root size="sm">…</Listbox.Root>
<Listbox.Root size="md">…</Listbox.Root>
<Listbox.Root size="lg">…</Listbox.Root>Disabled items
Mark a row disabled and it is dimmed and skipped by keyboard navigation and type-ahead. skipDisabled
(default true) governs the skipping; set it false to let the highlight land on disabled rows —
they still can't be selected. Disable the whole list with disabled on Listbox.Root.
<Listbox.Item value={fruit} disabled>
{fruit.name}
<Listbox.ItemIndicator />
</Listbox.Item>Virtualization
For very long lists, pass items (the full array) and estimateSize, and give Listbox.Root a
render-prop child (item, index) => <Listbox.Item index={index}>…. The list element becomes the
scroll container; only a window of rows mounts, so 10,000 rows scroll and navigate smoothly — try
End, or type to jump to a row. Selection, focus, and type-ahead all run over the full set, so
offscreen selections survive scrolling and submit with a form.
const items = Array.from({ length: 10_000 }, (_, i) => ({ id: i, name: `Item ${i}` }));
<Listbox.Root
aria-label="Ten thousand rows"
// Pure sizing — the fixed height/width makes the list a scroll viewport; no container chrome.
class="h-72 w-56"
items={items}
estimateSize={() => 32}
itemToValue={(item) => String(item.id)}
itemToLabel={(item) => item.name}
>
{(item, index) => (
<Listbox.Item index={index} style={{ height: "2rem" }}>
{item.name}
<Listbox.ItemIndicator />
</Listbox.Item>
)}
</Listbox.Root>;@tanstack/virtual-core, an optional peer of @hope-ui/primitives — install it in your app to use virtualization (a non-virtualizing install stays dependency-free). Virtual lists are flat: no Group / Separator.Native form submission
Set name and the listbox renders hidden inputs (siblings of the list element) valued
itemToValue(item) for each selected row — so a plain <form> submit carries the selection with no
extra wiring. The submitted strings are the itemToValue values (here, the fruit ids), and in
virtual multi-select even offscreen selections are included. Add form to associate the inputs with
a form by id, and required for native validation.
<form onSubmit={handleSubmit}>
<Listbox.Root
name="fruit"
selectionMode="multiple"
itemToValue={(f) => String(f.id)}
>
<For each={fruits}>
{(fruit) => <FruitItem fruit={fruit} />}
</For>
</Listbox.Root>
<Button type="submit">Submit</Button>
</form>Focus modes
focusMode decides where DOM focus lives. roving (the default) moves real focus onto the
active option and makes it the single tab stop — right for a standalone list. activedescendant
keeps focus on the listbox container and points aria-activedescendant at the active option — the
model a future Select / Combobox needs, where focus stays on the trigger or input while the
arrows drive the list. Tab into each below and arrow through it.
<Listbox.Root focusMode="roving">…</Listbox.Root>
<Listbox.Root focusMode="activedescendant">…</Listbox.Root>Reading direction (LTR & RTL)
An RTL direction mirrors the list — the check gutter moves to the left edge — and reverses a
horizontal listbox's arrow keys, where ← moves to the next option. A vertical listbox is
unaffected: RTL mirrors the inline axis only.
Set dir on your document root (see i18n) and every listbox follows. The wrapper
below is only needed because this page shows two directions at once:
<div dir="rtl">
<I18nProvider locale="ar-EG">
<Listbox.Root aria-label="اختر فاكهة">…</Listbox.Root>
</I18nProvider>
</div>Keyboard interactions
Listbox implements the WAI-ARIA listbox keyboard pattern. Navigation follows orientation (the
vertical arrows are shown; a horizontal listbox uses ← / →, reversed under RTL).
| Key | Action |
|---|---|
↓ / ↑ | Move the highlight to the next / previous option (disabled rows are skipped). |
Home / End | Move to the first / last option. |
PageDown / PageUp | Move by a page toward the end / start — useful in long or virtual lists. |
Enter | Select the highlighted option. |
Space | Select the highlighted option (single) or toggle it (multiple). |
Shift + ↓ / ↑ | Extend the selection to the next / previous option (multiple). |
⌘ / Ctrl + A | Select every option (multiple). |
type to search | Jump to the next option whose label starts with the typed characters (type-ahead). |
Polymorphism
Render a part as a different element or component with the render prop — a function that receives
the part's computed props and spreads them onto your element. Listbox.Item, Listbox.Group,
Listbox.GroupLabel, and Listbox.Separator all accept it. There is no as prop; render is
the single polymorphism API.
<Listbox.Item value={item} render={(props) => <a href={item.href} {...props} />}>
{item.name}
<Listbox.ItemIndicator />
</Listbox.Item>Theming
Listbox's look comes from the active preset's recipe. It is a neutral collection surface — no
color axis; the only accents are the transient highlight and the persistent selection, both driven by
tokens. Override styling at three levels, applied in order (later wins a Tailwind conflict): recipe
base → preset slotClasses → instance slotClasses / part class.
Parts
Every part carries a data-slot attribute you can target, and each is addressable by name through
slotClasses. The root slot deliberately carries no popup chrome — layer the elevated surface
yourself (see the note in Usage).
| Slot | data-slot | Description |
|---|---|---|
root | listbox | The list element (and scroll container in virtual mode). |
item | listbox-item | An option row — carries the highlight and selected/disabled state. |
itemIndicator | listbox-item-indicator | The selection check's placement in the trailing gutter. |
group | listbox-group | A labelled section wrapper. |
groupLabel | listbox-group-label | The small, muted section label. |
separator | listbox-separator | The hairline divider between sections. |
Overriding one listbox
Set slotClasses on Listbox.Root to reach any slot from one place, or put class on an individual
part. class on Listbox.Root merges onto the root slot — this is where the elevated-panel look
goes. Use literal class strings so your Tailwind build can see them.
<Listbox.Root
class="rounded-lg border border-subtle bg-surface-overlay shadow-md p-1"
slotClasses={{ item: "rounded-md", separator: "bg-strong/40" }}
>
{/* … */}
</Listbox.Root>App-wide defaults & overrides
Set the default size and global part classes for every Listbox through the theme with
definePreset, then pass the derived preset to ThemeProvider. defaultProps resolve at instance
?? preset ?? builtin precedence, so an explicit prop on a single listbox always wins.
import { definePreset } from "@hope-ui/theming";
import { hope } from "@hope-ui/presets/hope";
export const myPreset = definePreset(hope, {
components: {
listbox: {
defaultProps: { size: "sm" },
slotClasses: { root: "rounded-lg border border-subtle shadow-md p-1" },
},
},
});API
Listbox.Root
Listbox.Root<V> is generic in your item type V. It accepts the props below plus every native
<div> attribute (aria-label, style, data-*, …) so the list can be named and styled.
| Prop | Default | Type |
|---|---|---|
itemToValue | String | (item: V) => stringMaps an item to its selection identity — compared for equality, submitted to a form, and used as the item id in virtual mode. Must be unique per item. |
itemToLabel | — | (item: V) => stringMaps an item to its type-ahead / display text. In collection mode it falls back to the row's textContent; virtual mode needs it for offscreen type-ahead. |
value | — | V[]Controlled selection (an array in every mode). Omit for uncontrolled use via defaultValue. |
defaultValue | [] | V[]Initial selection when uncontrolled. |
onChange | — | (value: V[]) => voidCalled on every selection change with the new value array. |
selectionMode | "single" | "single" | "multiple" | "none"How many options can be selected at once. |
focusMode | "roving" | "roving" | "activedescendant"Whether the active option holds real DOM focus (roving) or the container holds focus and points aria-activedescendant at it. |
size | "md" | "sm" | "md" | "lg"The density scale — row text, padding, gap, and the panel's min width. |
orientation | "vertical" | "vertical" | "horizontal"The arrow-key axis and aria-orientation. |
dir | useLocale() | "ltr" | "rtl"Mirrors this listbox on its own, overriding the page's direction. Unset, it follows the page. |
disabled | false | booleanDisable the whole list — nothing tabbable, aria-disabled set. |
skipDisabled | true | booleanWhether keyboard navigation and type-ahead skip disabled items. |
wrap | false | booleanWhether arrow navigation wraps past the ends. |
isItemEqualToValue | by itemToValue | (a: V, b: V) => booleanFull override of value equality. Defaults to comparing itemToValue(a) === itemToValue(b). |
getItemDisabled | — | (item: V) => booleanVirtual mode: whether the item at an index is disabled (collection mode uses the item's disabled prop). |
items | — | readonly V[]Virtual mode: the full data array. Supply with estimateSize to enable windowing. |
estimateSize | — | (index: number) => numberVirtual mode: estimated row size in px by index. Its presence (with items) selects virtual mode. |
overscan | 5 | numberVirtual mode: extra rows rendered beyond the visible window. |
name | — | stringNative form field name. When set, hidden inputs are rendered from the selection. |
form | — | stringAssociates the hidden field(s) with a form by id. |
required | false | booleanMarks the field required for native validation. |
slotClasses | — | SlotClasses<"listbox">Per-slot class overrides, keyed by root, item, itemIndicator, group, groupLabel, separator. |
render | — | (props) => JSX.ElementRender the list container as another element or component. Receives Root's computed props; honour the ref it passes (it is the scroll container in virtual mode). |
class | — | stringMerged onto the root slot (applied last) — where the elevated-panel look goes. |
children | — | JSX.Element | ((item: V, index: Accessor<number>) => JSX.Element)The Item/Group children (collection), or a render-prop invoked per windowed row (virtual). |
Listbox.Item
Provide exactly one of value (collection mode) or index (virtual mode). Plus every native
<div> attribute.
| Prop | Default | Type |
|---|---|---|
value | — | VCollection mode: this option's selection identity. Required in collection mode; ignored in virtual mode. |
index | — | Accessor<number>Virtual mode: this row's index into the full items array (as an accessor). Its presence selects the virtual path. |
disabled | false | booleanWhether this option is disabled (skipped by focus/navigation and dimmed). |
textValue | — | stringExplicit type-ahead text, overriding itemToLabel / the row's textContent. |
render | — | (props) => JSX.ElementRender the option as another element or component. Receives the Item's computed props. |
class | — | stringExtra classes merged onto the item slot (your utilities win conflicts). |
Listbox.ItemIndicator
Plus every native <span> attribute. aria-hidden is not one of them — the glyph stays hidden from
assistive tech, since the option's own aria-selected already conveys the selection.
| Prop | Default | Type |
|---|---|---|
children | built-in check | JSX.ElementA custom selection glyph. Shown only while the row is selected. |
render | — | (props) => JSX.ElementRender the indicator as another element or component. Receives its computed props. |
class | — | stringExtra classes merged onto the itemIndicator slot (your utilities win conflicts). |
Parts
Listbox.Group, Listbox.GroupLabel, and Listbox.Separator each accept their native <div>
attributes plus a render prop and a class. All three are collection mode only.
Accessibility
Listbox implements the WAI-ARIA Listbox pattern.
- Roles and state are wired for you. The list is
role="listbox", each optionrole="option"witharia-selected, groupsrole="group", and the separatorrole="presentation". Multi-select setsaria-multiselectableon the list. - Give the list an accessible name. There's no built-in label, so pass
aria-label(oraria-labelledbypointing at your own heading) onListbox.Root. - Two focus models.
rovingmoves DOM focus to the active option (the standalone default);activedescendantkeeps focus on the container and pointsaria-activedescendantat the active option — what a trigger-ownedSelectneeds. - One highlight, keyboard and pointer. Arrows and the pointer share a single active option, so the cursor and the keyboard never paint two highlights; selection (the check) is separate from the transient highlight.
- The highlight follows focus. The active option is highlighted only while the list has focus, so it never lingers after you tab away. Entering the list highlights the selected option if there is one, otherwise the first — and Tab lands there directly.
- Disabled options stay discoverable. A disabled row keeps
aria-disabledand, by default, is skipped by navigation; it can never be selected. - Type-ahead. Typing focuses the next option whose label matches — from
itemToLabel, an explicittextValue, or the row's text.