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 lists of thousands of rows.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 takes your options as items and owns the state (selection, focus, keyboard
navigation, type-ahead, ARIA) plus the role="listbox" element. Its child is a render callback
invoked once per item — return a Listbox.Item item={item}, with an optional
Listbox.ItemIndicator to paint 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.
Your items are your own objects and stay that way: every question the listbox asks about one is an accessor function, never a required key name.
const [value, setValue] = createSignal<Fruit[]>([]);
<Listbox.Root
aria-label="Choose a fruit"
items={fruits}
itemToValue={(fruit) => String(fruit.id)}
itemToLabel={(fruit) => fruit.name}
value={value()}
onChange={setValue}
>
{(fruit) => (
<Listbox.Item item={fruit}>
{fruit.name}
<Listbox.ItemIndicator />
</Listbox.Item>
)}
</Listbox.Root>;root slot carries no popup chrome (no background, border, shadow, or padding), so a bare list sits in the page flow, which is what the demos on this page show. A floating consumer — Select or Combobox — is what layers the elevated surface on top, and you can do the same with a class (see Theming).<ThemeProvider> ancestor fed a preset, as every styled hope-ui component does.Anatomy
Listbox.Root renders the list element and invokes your callback once per items entry;
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 three appear once you set groupToItems (see Groups), and none of them
are available in virtual mode, which is flat.
| Part | Element | Description |
|---|---|---|
Listbox.Root | div[role=listbox] | State owner and the list element (the scroll container in virtual mode). Owns the items, selection, focus, keyboard, type-ahead, ids, and the resolved recipe. Its child is a render callback invoked once per entry. |
Listbox.Item | div[role=option] | One option. Owns aria-selected, the active/disabled data attributes, and the click/pointer handlers. Give it item (the entry it renders) or, in virtual mode, index. |
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 labeled section, named by its GroupLabel via aria-labelledby. Needs groupToItems; not available in virtual mode. |
Listbox.GroupLabel | div | Names its Group. Self-registers its id as the group’s aria-labelledby. |
Listbox.Separator | div[role=presentation] | A decorative hairline between sections (aria-hidden — never reported as an option). |
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
Set groupToItems and items becomes your group entries: it flattens them into navigation
order, which is the only thing the listbox needs from a group. Your callback then goes one level up —
it is invoked per group, and you iterate that group’s own items with a plain <For>.
The group’s name never reaches the listbox: you render it from your own key, which is why there is
no groupToLabel and no { label, items } shape to conform to. Wrap each section 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 data mode only — a virtual listbox is flat.
// Nested straight from your API — no remapping, no required key names.
// [{ kind: "Citrus", fruits: [{ id, name }, …] }, …]
<Listbox.Root
aria-label="Choose a fruit"
items={baskets}
groupToItems={(basket) => basket.fruits}
itemToValue={itemToValue}
itemToLabel={itemToLabel}
>
{(basket, index) => (
<>
<Show when={index() > 0}>
<Listbox.Separator />
</Show>
<Listbox.Group>
<Listbox.GroupLabel>{basket.kind}</Listbox.GroupLabel>
<For each={basket.fruits}>
{(fruit) => <FruitItem fruit={fruit} />}
</For>
</Listbox.Group>
</>
)}
</Listbox.Root>items order — that is what arrow keys and type-ahead traverse. It holds by construction when the inner <For> iterates the array groupToItems returned, and an item that isn’t in items logs a development warning naming it.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
Answer isItemDisabled and the matching rows are dimmed and skipped by keyboard navigation and
type-ahead. It is a data question, like every other one here, so a row’s disabled state is known
before it ever mounts — which is what lets a closed Select skip it. 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.Root isItemDisabled={(fruit) => fruit.outOfSeason}>
{(fruit) => (
<Listbox.Item item={fruit}>
{fruit.name}
<Listbox.ItemIndicator />
</Listbox.Item>
)}
</Listbox.Root>Virtualization
For lists of thousands of rows, add estimateSize and the same callback child becomes per
windowed row,
returning a Listbox.Item index={index} — a recycled row’s position is the only thing it knows, so
it is told rather than resolving its own. The list element becomes the scroll container; only a
window of rows mounts, so the 10,000 rows below scroll and navigate at full frame rate — 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.
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, and no groupToItems.Native form submission
Set name and the listbox renders a visually-hidden native <select> beside the list — 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 field with a form by id.
Because it is a real <select> holding one <option> per item, three things come for free that
a hidden input cannot give you: the browser can autofill it, required genuinely blocks
submission (and moves focus to the list rather than to a control nobody can see), and the form’s
reset button puts the selection back to where it started.
<form onSubmit={handleSubmit}>
<Listbox.Root
name="fruit"
selectionMode="multiple"
items={fruits}
itemToValue={(f) => String(f.id)}
>
{(fruit) => <FruitItem fruit={fruit} />}
</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 Select and Combobox use, 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 and 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 item={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.
Part slots
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 labeled 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 and 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 |
|---|---|---|
items | — | readonly G[]Required. The whole option set, in navigation order — your items for a flat list, or the group entries when groupToItems is set. The rows are rendered by the children callback, once per entry. |
groupToItems | — | (group: G) => readonly V[]Maps a group entry to its own items, flattening items into navigation order. Setting it switches the children callback from per-item to per-group. Not combinable with estimateSize. |
itemToValue | String | (item: V) => stringMaps an item to its selection identity — compared for equality and submitted to a form. Must be unique per item. It is not the row’s DOM id, which is generated. |
itemToLabel | itemToValue | (item: V) => stringMaps an item to its type-ahead / display text. Read from the data, so it works for a row that has not mounted — there is no textContent fallback. |
isItemDisabled | false | (item: V) => booleanWhether an item is disabled — dimmed, and skipped by focus and type-ahead unless skipDisabled is off. |
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). |
estimateSize | — | (index: number) => numberEstimated row size in px by index. Its presence selects virtual mode (windowing). |
overscan | 5 | numberVirtual mode: extra rows rendered beyond the visible window. |
name | — | stringNative form field name. When set, a visually-hidden native <select> is rendered from the selection. |
form | — | stringAssociates the hidden field with a form by id. |
required | false | booleanMarks the field required: an empty selection blocks the form submit and moves focus to the list. |
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; honor 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 | — | (entry: G, index: Accessor<number>) => JSX.ElementA render callback invoked once per items entry: per item when flat, per group when groupToItems is set, per windowed row in virtual mode. index is what a Separator between groups keys off. |
Listbox.Item
Provide exactly one of item (the normal case) or index (virtual mode). Plus every native
<div> attribute — id is the exception: it is the aria-activedescendant target and is generated
for you. Nothing else about the row is declared here; its label, disabled state and value all come
from Listbox.Root’s accessors.
| Prop | Default | Type |
|---|---|---|
item | — | VThe entry this option renders — one element of Listbox.Root’s items. Required outside virtual mode; the row resolves its own position from it, so it can sit at any depth (a group’s nested For). |
index | — | Accessor<number>Virtual mode: this row’s index into the full items array (as an accessor). Its presence selects the virtual path. |
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). |
Part props
Listbox.Group, Listbox.GroupLabel, and Listbox.Separator each accept their native <div>
attributes plus a render prop and a class. All three belong to the grouped (groupToItems) shape
and none are available in virtual mode.
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, read from
itemToLabel(elseitemToValue). Because it comes from your data rather than the rendered text, it reaches rows that have not mounted — an offscreen row in a virtual list, and every option of a closed Select.