hope-uiearly-preview

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.

Apple
Banana
Cherry
Date
Elderberry
Fig
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>;

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.

Listbox.Root
├── Listbox.Item
│ └── Listbox.ItemIndicator
├── Listbox.Group
│ ├── Listbox.GroupLabel
│ └── Listbox.Item
│ └── Listbox.ItemIndicator
└── Listbox.Separator
PartElementDescription
Listbox.Rootdiv[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.Itemdiv[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.ItemIndicatorspanThe selection check, shown only while its row is selected. Defaults to a built-in check; pass children for a custom glyph.
Listbox.Groupdiv[role=group]A labelled section, named by its GroupLabel via aria-labelledby. Collection mode only.
Listbox.GroupLabeldivNames its Group. Self-registers its id as the group's aria-labelledby. Collection mode only.
Listbox.Separatordiv[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+A selects 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.

Apple
Banana
Cherry
Date
Elderberry
Fig
multiple
<Listbox.Root selectionMode="multiple" value={value()} onChange={setValue}>
  {/* … */}
</Listbox.Root>
Apple
Banana
Cherry
Date
Elderberry
Fig
none
<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.

Citrus
Orange
Lemon
Lime
Berries
Strawberry
Blueberry
Raspberry
<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.

sm
Apple
Banana
Cherry
Date
md
Apple
Banana
Cherry
Date
lg
Apple
Banana
Cherry
Date
<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.

Apple
Banana
Cherry
Date
Elderberry
Fig
<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>;

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.

Apple
Banana
Cherry
Date
Elderberry
Fig
Not submitted yet
<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.

roving
Apple
Banana
Cherry
Date
activedescendant
Apple
Banana
Cherry
Date
<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.

en-US (LTR)
Apple
Banana
Cherry
Date
Elderberry
Fig
ar-EG (RTL)
تفاح
موز
كرز
تمر
بيلسان
تين

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

KeyAction
↓ / ↑Move the highlight to the next / previous option (disabled rows are skipped).
Home / EndMove to the first / last option.
PageDown / PageUpMove by a page toward the end / start — useful in long or virtual lists.
EnterSelect the highlighted option.
SpaceSelect the highlighted option (single) or toggle it (multiple).
Shift + ↓ / ↑Extend the selection to the next / previous option (multiple).
⌘ / Ctrl + ASelect every option (multiple).
type to searchJump 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.

Item as a link row
<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).

Slotdata-slotDescription
rootlistboxThe list element (and scroll container in virtual mode).
itemlistbox-itemAn option row — carries the highlight and selected/disabled state.
itemIndicatorlistbox-item-indicatorThe selection check's placement in the trailing gutter.
grouplistbox-groupA labelled section wrapper.
groupLabellistbox-group-labelThe small, muted section label.
separatorlistbox-separatorThe 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.

theme.ts
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.

PropDefaultType
itemToValueString
(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.
diruseLocale()
"ltr" | "rtl"Mirrors this listbox on its own, overriding the page's direction. Unset, it follows the page.
disabledfalse
booleanDisable the whole list — nothing tabbable, aria-disabled set.
skipDisabledtrue
booleanWhether keyboard navigation and type-ahead skip disabled items.
wrapfalse
booleanWhether arrow navigation wraps past the ends.
isItemEqualToValueby 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.
overscan5
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.
requiredfalse
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.

PropDefaultType
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.
disabledfalse
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.

PropDefaultType
childrenbuilt-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 option role="option" with aria-selected, groups role="group", and the separator role="presentation". Multi-select sets aria-multiselectable on the list.
  • Give the list an accessible name. There's no built-in label, so pass aria-label (or aria-labelledby pointing at your own heading) on Listbox.Root.
  • Two focus models. roving moves DOM focus to the active option (the standalone default); activedescendant keeps focus on the container and points aria-activedescendant at the active option — what a trigger-owned Select needs.
  • 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-disabled and, by default, is skipped by navigation; it can never be selected.
  • Type-ahead. Typing focuses the next option whose label matches — from itemToLabel, an explicit textValue, or the row's text.