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 keydata-slotElement
baselist-box<div role="listbox"> root
itemlist-box-item<div role="option">
indicatorlist-box-item-indicatorcheckmark wrapper
section—<section> per section
headerheader<header> section label
separatorseparator<hr>
emptyStateempty-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 string

Examples

With Disabled Items

Actions
Create a new file
⌘N
Make changes
⌘E

Danger zone
Move to trash
⌘⇧D
<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

Actions
Create a new file
⌘N
Make changes
⌘E

Danger zone
Move to trash
⌘⇧D
<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

<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

Aardvark
Alpaca
Antelope
Bear
Cat
Dog
Fox
Giraffe
Kangaroo
Koala
Lemur
Otter
Panda
Penguin
Rabbit
Snake
Turtle
Wombat
Zebra

Browser default

Aardvark
Alpaca
Antelope
Bear
Cat
Dog
Fox
Giraffe
Kangaroo
Koala
Lemur
Otter
Panda
Penguin
Rabbit
Snake
Turtle
Wombat
Zebra

Hidden

Aardvark
Alpaca
Antelope
Bear
Cat
Dog
Fox
Giraffe
Kangaroo
Koala
Lemur
Otter
Panda
Penguin
Rabbit
Snake
Turtle
Wombat
Zebra
<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: :hover or [data-hovered="true"] on the item
  • Focus: :focus-visible or [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

PropTypeDefaultDescription
itemsListBoxEntry[]-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.
defaultValueListBoxValue | ListBoxValue[] | nullundefinedThe initial selection when uncontrolled (no v-model).
indicatorbooleantrueWhether to render the checkmark indicator in selectable items.
disabledbooleanfalseWhether the whole list is disabled.
virtualizeboolean | { estimateSize?: number | undefined; overscan?: number | undefined; }falseRender 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.
loadingbooleanundefinedAsync 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.
uiComponentSlots<{ 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.
modelValueListBoxValue | ListBoxValue[] | null--

Slots

SlotPropsDescription
itemListBoxItemSlotPropsWhole content of each item. Replaces leading, label, description, trailing and indicator.
item-leadingListBoxItemSlotPropsContent before the item text (avatar, icon).
item-labelListBoxItemSlotPropsThe label of each item. Replaces item.label.
item-descriptionListBoxItemSlotPropsThe description of each item. Replaces item.description.
item-trailingListBoxItemSlotPropsContent after the item text (shortcut, badge).
indicatorListBoxItemSlotPropsIndicator content. Defaults to an animated checkmark.
section-label{ section: ListBoxSection; }Section heading. Replaces section.label.
emptyanyRendered when items is empty.
loadinganyShown after the last item while loading is true (e.g. a Spinner).

Emits

EventPayloadDescription
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-selected on selectable options, aria-multiselectable in multiple mode
  • Disabled items are skipped by keyboard navigation

On this page

No Headings