ListBox
A listbox displays a list of options and allows a user to select one or more of them
Usage
<script setup lang="ts">
import { ListBox } from '@hareui/vue'
</script><script setup lang="ts">import type { ListBoxItem } from '@hareui/vue'import { Avatar, ListBox } from '@hareui/vue'interface User extends ListBoxItem { avatar: string}const users: User[] = [ { value: '1', label: 'Bob', description: '[email protected]', avatar: 'https://heroui-assets.nyc3.cdn.digitaloceanspaces.com/avatars/blue.jpg' }, { value: '2', label: 'Fred', description: '[email protected]', avatar: 'https://heroui-assets.nyc3.cdn.digitaloceanspaces.com/avatars/green.jpg' }, { value: '3', label: 'Martha', description: '[email protected]', avatar: 'https://heroui-assets.nyc3.cdn.digitaloceanspaces.com/avatars/purple.jpg' },]</script><template> <ListBox aria-label="Users" class="w-[220px]" :items="users"> <template #item-leading="{ item }"> <Avatar size="sm" :alt="item.label" :src="item.avatar" :fallback="item.label?.[0]" /> </template> </ListBox></template>Anatomy
ListBox takes an items array. Each entry is an item ({ value, label, description, disabled, variant, textValue, class }), a section ({ type: 'section', label, items }) or a separator ({ type: 'separator' }). Each item renders a Label, a Description and a checkmark indicator; slots replace any part.
<template>
<ListBox :items="items">
<template #item-leading="{ item, selected }" /> <!-- before the text (avatar, icon) -->
<template #item-label="{ item }" /> <!-- Label content -->
<template #item-description="{ item }" /> <!-- Description content -->
<template #item-trailing="{ item }" /> <!-- after the text (shortcut) -->
<template #indicator="{ selected }" /> <!-- indicator content (default: checkmark) -->
<template #item="{ item, index, selected, disabled }" /> <!-- whole item content -->
<template #section-label="{ section }" /> <!-- section header -->
<template #empty /> <!-- shown when items is empty -->
</ListBox>
</template>ui key | data-slot | Element |
|---|---|---|
base | list-box | <div role="listbox"> root |
item | list-box-item | <div role="option"> |
indicator | list-box-item-indicator | checkmark wrapper |
section | — | <section> per section |
header | header | <header> section label |
separator | separator | <hr> |
emptyState | empty-state | <div> (iff items is empty and #empty given) |
Typing items
items takes the exported ListBoxItem type (ListBoxEntry when it mixes in sections and separators). Extra fields are kept: extend the type, and the slots receive your type, so no casts are needed.
import type { ListBoxItem } from '@hareui/vue'
interface MyItem extends ListBoxItem {
icon: string
}
const items: MyItem[] = [/* ... */]
// <template #item-leading="{ item }"> — item.icon is a stringExamples
With Disabled Items
<script setup lang="ts">import { Icon } from '@iconify/vue'import type { KbdKey, ListBoxEntry, ListBoxItem } from '@hareui/vue'import { Kbd, ListBox, Surface } from '@hareui/vue'interface FileAction extends ListBoxItem { icon: { name: string, cls: string } keys: KbdKey[] key: string}const icon = (name: string, cls = 'text-muted') => ({ name, cls })const sections = (dangerDisabled: boolean): ListBoxEntry<FileAction>[] => [ { type: 'section', label: 'Actions', items: [ { value: 'new-file', label: 'New file', description: 'Create a new file', icon: icon('gravity-ui:square-plus'), keys: ['command'], key: 'N' }, { value: 'edit-file', label: 'Edit file', description: 'Make changes', icon: icon('gravity-ui:pencil'), keys: ['command'], key: 'E' }, ] }, { type: 'separator' }, { type: 'section', label: 'Danger zone', items: [ { value: 'delete-file', label: 'Delete file', description: 'Move to trash', variant: 'danger', disabled: dangerDisabled, icon: icon('gravity-ui:trash-bin', 'text-danger'), keys: ['command', 'shift'], key: 'D' }, ] },]const alert = (message: string) => window.alert(message)</script><template> <Surface class="w-[256px] rounded-3xl shadow-surface"> <ListBox aria-label="File actions" class="w-full p-2" selection-mode="none" :items="sections(true)" @action="value => alert(`Selected item: ${value}`)" > <template #item-leading="{ item }"> <div class="flex h-8 items-start justify-center pt-px"> <Icon :class="['size-4 shrink-0', item.icon.cls]" :icon="item.icon.name" /> </div> </template> <template #item-trailing="{ item }"> <Kbd class="ms-auto" variant="light" :keys="item.keys"> {{ item.key }} </Kbd> </template> </ListBox> </Surface></template>With Sections
<script setup lang="ts">import { Icon } from '@iconify/vue'import type { KbdKey, ListBoxEntry, ListBoxItem } from '@hareui/vue'import { Kbd, ListBox, Surface } from '@hareui/vue'interface FileAction extends ListBoxItem { icon: { name: string, cls: string } keys: KbdKey[] key: string}const icon = (name: string, cls = 'text-muted') => ({ name, cls })const sections = (dangerDisabled: boolean): ListBoxEntry<FileAction>[] => [ { type: 'section', label: 'Actions', items: [ { value: 'new-file', label: 'New file', description: 'Create a new file', icon: icon('gravity-ui:square-plus'), keys: ['command'], key: 'N' }, { value: 'edit-file', label: 'Edit file', description: 'Make changes', icon: icon('gravity-ui:pencil'), keys: ['command'], key: 'E' }, ] }, { type: 'separator' }, { type: 'section', label: 'Danger zone', items: [ { value: 'delete-file', label: 'Delete file', description: 'Move to trash', variant: 'danger', disabled: dangerDisabled, icon: icon('gravity-ui:trash-bin', 'text-danger'), keys: ['command', 'shift'], key: 'D' }, ] },]const alert = (message: string) => window.alert(message)</script><template> <Surface class="w-[256px] rounded-3xl shadow-surface"> <ListBox aria-label="File actions" class="w-full p-2" selection-mode="none" :items="sections(false)" @action="value => alert(`Selected item: ${value}`)" > <template #item-leading="{ item }"> <div class="flex h-8 items-start justify-center pt-px"> <Icon :class="['size-4 shrink-0', item.icon.cls]" :icon="item.icon.name" /> </div> </template> <template #item-trailing="{ item }"> <Kbd class="ms-auto" variant="light" :keys="item.keys"> {{ item.key }} </Kbd> </template> </ListBox> </Surface></template>Multi Select
<script setup lang="ts">import type { ListBoxItem } from '@hareui/vue'import { Avatar, ListBox, Surface } from '@hareui/vue'interface User extends ListBoxItem { avatar: string}const users: User[] = [ { value: '1', label: 'Bob', description: '[email protected]', avatar: 'https://heroui-assets.nyc3.cdn.digitaloceanspaces.com/avatars/blue.jpg' }, { value: '2', label: 'Fred', description: '[email protected]', avatar: 'https://heroui-assets.nyc3.cdn.digitaloceanspaces.com/avatars/green.jpg' }, { value: '3', label: 'Martha', description: '[email protected]', avatar: 'https://heroui-assets.nyc3.cdn.digitaloceanspaces.com/avatars/purple.jpg' },]</script><template> <Surface class="w-[256px] rounded-3xl shadow-surface"> <ListBox aria-label="Users" selection-mode="multiple" :items="users"> <template #item-leading="{ item }"> <Avatar size="sm" :alt="item.label" :src="item.avatar" :fallback="item.label?.[0]" /> </template> </ListBox> </Surface></template>Controlled
Selected: 1
<script setup lang="ts">import { Icon } from '@iconify/vue'import type { ListBoxItem } from '@hareui/vue'import { Avatar, ListBox, Surface } from '@hareui/vue'import { ref } from 'vue'interface User extends ListBoxItem { avatar: string}const users: User[] = [ { value: '1', label: 'Bob', description: '[email protected]', avatar: 'https://heroui-assets.nyc3.cdn.digitaloceanspaces.com/avatars/blue.jpg' }, { value: '2', label: 'Fred', description: '[email protected]', avatar: 'https://heroui-assets.nyc3.cdn.digitaloceanspaces.com/avatars/green.jpg' }, { value: '3', label: 'Martha', description: '[email protected]', avatar: 'https://heroui-assets.nyc3.cdn.digitaloceanspaces.com/avatars/purple.jpg' },]const selected = ref<string[]>(['1'])</script><template> <div class="space-y-4"> <Surface class="w-[256px] rounded-3xl shadow-surface"> <ListBox aria-label="Users" v-model="selected" selection-mode="multiple" :items="users"> <template #item-leading="{ item }"> <Avatar size="sm" :alt="item.label" :src="item.avatar" :fallback="item.label?.[0]" /> </template> <template #indicator="{ selected }"> <Icon v-if="selected" class="size-4 text-accent-soft-foreground" icon="gravity-ui:check" /> </template> </ListBox> </Surface> <p class="text-sm text-muted"> Selected: {{ selected.length > 0 ? selected.join(', ') : 'None' }} </p> </div></template>Virtualization
virtualize renders only the visible rows through Reka's ListboxVirtualizer. The root must scroll (give it a height and overflow-y-auto), and items must be flat. Pass { estimateSize, overscan } to tune the row height estimate.
<script setup lang="ts">import type { ListBoxItem } from '@hareui/vue'import { ListBox } from '@hareui/vue'const firstNames = ['Emma', 'Liam', 'Olivia', 'Noah', 'Ava', 'James', 'Sophia', 'Oliver', 'Isabella', 'Lucas', 'Mia', 'Ethan', 'Charlotte', 'Mason', 'Amelia', 'Logan', 'Harper', 'Alexander', 'Ella', 'Benjamin']const lastNames = ['Smith', 'Johnson', 'Williams', 'Brown', 'Jones', 'Garcia', 'Miller', 'Davis', 'Rodriguez', 'Martinez', 'Anderson', 'Taylor', 'Thomas', 'Jackson', 'White', 'Harris', 'Clark', 'Lewis', 'Robinson', 'Walker']const users: ListBoxItem[] = Array.from({ length: 1000 }, (_, i) => { const first = firstNames[i % firstNames.length]! const last = lastNames[Math.floor(i / firstNames.length) % lastNames.length]! return { value: i + 1, label: `${first} ${last}`, description: `${first.toLowerCase()}.${last.toLowerCase()}@acme.com` }})</script><template> <ListBox aria-label="Virtualized list with 1000 items" class="h-[400px] w-[300px] overflow-y-auto" :items="users" :virtualize="{ estimateSize: 50 }" /></template>Custom Check Icon
<script setup lang="ts">import { Icon } from '@iconify/vue'import type { ListBoxItem } from '@hareui/vue'import { Avatar, ListBox, Surface } from '@hareui/vue'interface User extends ListBoxItem { avatar: string}const users: User[] = [ { value: '1', label: 'Bob', description: '[email protected]', avatar: 'https://heroui-assets.nyc3.cdn.digitaloceanspaces.com/avatars/blue.jpg' }, { value: '2', label: 'Fred', description: '[email protected]', avatar: 'https://heroui-assets.nyc3.cdn.digitaloceanspaces.com/avatars/green.jpg' }, { value: '3', label: 'Martha', description: '[email protected]', avatar: 'https://heroui-assets.nyc3.cdn.digitaloceanspaces.com/avatars/purple.jpg' },]</script><template> <Surface class="w-[256px] rounded-3xl shadow-surface"> <ListBox aria-label="Users" selection-mode="multiple" :items="users"> <template #item-leading="{ item }"> <Avatar size="sm" :alt="item.label" :src="item.avatar" :fallback="item.label?.[0]" /> </template> <template #indicator="{ selected }"> <Icon v-if="selected" class="size-4 text-accent-soft-foreground" icon="gravity-ui:check" /> </template> </ListBox> </Surface></template>Render Function
HeroUI's render prop has no HareUI equivalent; attributes such as data-custom fall through to the root.
<script setup lang="ts">import type { ListBoxItem } from '@hareui/vue'import { Avatar, ListBox } from '@hareui/vue'interface User extends ListBoxItem { avatar: string}const users: User[] = [ { value: '1', label: 'Bob', description: '[email protected]', avatar: 'https://heroui-assets.nyc3.cdn.digitaloceanspaces.com/avatars/blue.jpg' }, { value: '2', label: 'Fred', description: '[email protected]', avatar: 'https://heroui-assets.nyc3.cdn.digitaloceanspaces.com/avatars/green.jpg' }, { value: '3', label: 'Martha', description: '[email protected]', avatar: 'https://heroui-assets.nyc3.cdn.digitaloceanspaces.com/avatars/purple.jpg' },]</script><template> <!-- HeroUI's `render` prop has no Vue equivalent (plan drop: render functions); attributes fall through instead. --> <ListBox aria-label="Users" class="w-[220px]" data-custom="true" :items="users"> <template #item-leading="{ item }"> <Avatar size="sm" :alt="item.label" :src="item.avatar" :fallback="item.label?.[0]" /> </template> </ListBox></template>Scrollbar Modes
HeroUI thin
Browser default
Hidden
<script setup lang="ts">import type { ListBoxItem } from '@hareui/vue'import { ListBox, Surface } from '@hareui/vue'const modes = [ { id: 'heroui', label: 'HeroUI thin', scrollbar: 'thin' }, { id: 'browser', label: 'Browser default', scrollbar: 'default' }, { id: 'hidden', label: 'Hidden', scrollbar: 'none' },]const animals = ['Aardvark', 'Alpaca', 'Antelope', 'Bear', 'Cat', 'Dog', 'Fox', 'Giraffe', 'Kangaroo', 'Koala', 'Lemur', 'Otter', 'Panda', 'Penguin', 'Rabbit', 'Snake', 'Turtle', 'Wombat', 'Zebra']const itemsFor = (mode: string): ListBoxItem[] => animals.map(name => ({ value: `${mode}-${name.toLowerCase()}`, label: name, class: 'text-sm leading-5 font-medium' }))</script><template> <div class="flex w-full flex-wrap justify-center gap-4"> <div v-for="mode in modes" :key="mode.id" class="flex w-[260px] flex-col gap-2"> <h3 class="px-1 text-sm font-semibold text-muted"> {{ mode.label }} </h3> <Surface class="overflow-hidden rounded-3xl shadow-surface" :data-scrollbar="mode.scrollbar"> <div class="h-52 scrollbar overflow-y-auto p-1"> <ListBox :aria-label="`${mode.label} animals`" :items="itemsFor(mode.id)"> <template #item="{ item }"> {{ item.label }} </template> </ListBox> </div> </Surface> </div> </div></template>Customization
Tailwind CSS
<script setup lang="ts">import type { ListBoxItem } from '@hareui/vue'import { Avatar, ListBox } from '@hareui/vue'interface User extends ListBoxItem { avatar: string}const users: User[] = [ { value: '1', label: 'Bob', description: '[email protected]', avatar: 'https://heroui-assets.nyc3.cdn.digitaloceanspaces.com/avatars/blue.jpg' }, { value: '2', label: 'Fred', description: '[email protected]', avatar: 'https://heroui-assets.nyc3.cdn.digitaloceanspaces.com/avatars/green.jpg' },]</script><template> <ListBox aria-label="Assignee" class="w-56 rounded-xl border border-border/80 bg-surface p-1 shadow-sm" :items="users.slice(0, 2)" :ui="{ item: 'rounded-lg data-[focused=true]:bg-accent/10 data-[selected=true]:bg-accent/5' }" > <template #item-leading="{ item }"> <Avatar size="sm" :alt="item.label" :src="item.avatar" :fallback="item.label?.[0]" /> </template> </ListBox></template>Global Configuration
app.use(createHareUI({
ui: { listBox: { slots: { base: 'rounded-lg border border-border p-2', item: 'rounded px-2 py-1' } } },
}))Styling Reference
Interactive States
- Hover:
:hoveror[data-hovered="true"]on the item - Focus:
:focus-visibleor[data-focus-visible="true"]on the item - Selected:
[data-selected="true"]on the item - Disabled:
[data-disabled]on the item - Danger:
variant: 'danger'on an item
API Reference
Props
| Prop | Type | Default | Description |
|---|---|---|---|
items | ListBoxEntry[] | - | Items, sections ({ type: 'section', label, items }) and separators ({ type: 'separator' }).
Extra fields on the items are kept and typed in the item slots. |
selectionMode | "multiple" | "single" | "none" | "single" | How many items can be selected. With 'none' items only emit action. |
defaultValue | ListBoxValue | ListBoxValue[] | null | undefined | The initial selection when uncontrolled (no v-model). |
indicator | boolean | true | Whether to render the checkmark indicator in selectable items. |
disabled | boolean | false | Whether the whole list is disabled. |
virtualize | boolean | { estimateSize?: number | undefined; overscan?: number | undefined; } | false | Render only the visible rows (Reka ListboxVirtualizer). Needs a scrolling root (e.g. class="h-[400px] overflow-y-auto")
and flat items (sections and separators are skipped). Pass { estimateSize, overscan } to tune. |
loading | boolean | undefined | Async collections (RAC ListBoxLoadMoreItem): when set, a sentinel after the last item emits load-more
whenever it scrolls into view while loading is false; while loading is true the #loading slot is shown there. |
ui | ComponentSlots<{ slots: { base: string[]; item: string[]; indicator: string[]; section: string; header: string; separator: string; emptyState: string; }; variants: { variant: { default: {}; danger: { item: string; indicator: string; }; }; }; defaultVariants: { variant: string; }; }> | - | Per-slot class overrides. |
modelValue | ListBoxValue | ListBoxValue[] | null | - | - |
Slots
| Slot | Props | Description |
|---|---|---|
item | ListBoxItemSlotProps | Whole content of each item. Replaces leading, label, description, trailing and indicator. |
item-leading | ListBoxItemSlotProps | Content before the item text (avatar, icon). |
item-label | ListBoxItemSlotProps | The label of each item. Replaces item.label. |
item-description | ListBoxItemSlotProps | The description of each item. Replaces item.description. |
item-trailing | ListBoxItemSlotProps | Content after the item text (shortcut, badge). |
indicator | ListBoxItemSlotProps | Indicator content. Defaults to an animated checkmark. |
section-label | { section: ListBoxSection; } | Section heading. Replaces section.label. |
empty | any | Rendered when items is empty. |
loading | any | Shown after the last item while loading is true (e.g. a Spinner). |
Emits
| Event | Payload | Description |
|---|---|---|
loadMore | [] | - |
action | [value: ListBoxValue] | - |
update:modelValue | [value: ListBoxValue | ListBoxValue[] | null | undefined] | - |
Accessibility
Built on Reka's Listbox, which implements the ARIA listbox pattern:
- Arrow keys, Home/End and typeahead navigation
aria-selectedon selectable options,aria-multiselectablein multiple mode- Disabled items are skipped by keyboard navigation







