<script setup lang="ts">
import { ref } from 'vue'
import { Listbox } from '@hina-ui/vue'
const status = ref('doing')
const statuses = [
{ value: 'wish', label: 'Want to read' },
{ value: 'doing', label: 'Reading' },
{ value: 'done', label: 'Finished' },
{ value: 'dropped', label: 'Dropped' },
]
</script>
<template>
<Listbox v-model="status" :options="statuses" aria-label="Collection status" class="w-56" />
</template>
Usage
import { Listbox } from '@hina-ui/vue'
The listbox keeps its options on the page and shares its option data with Select. v-model binds the chosen value, or an array once multiple is set. The list scrolls inside its box past maxHeight. Attributes it does not declare land on the inner list element.
Current value: none
<script setup lang="ts">
import { ref } from 'vue'
import { Listbox, Stack, Text } from '@hina-ui/vue'
const type = ref<string | null>(null)
const types = [
{ value: 'gal', label: 'Galgame' },
{ value: 'ln', label: 'Light novel' },
{ value: 'manga', label: 'Manga' },
{ value: 'anime', label: 'Anime' },
]
</script>
<template>
<Stack class="w-56">
<Listbox v-model="type" :options="types" aria-label="Work type" />
<Text tone="muted">Current value: {{ type ?? 'none' }}</Text>
</Stack>
</template>
Examples
Multiple
With multiple set several options can be chosen at once; clicking again unticks.
Chosen: school
<script setup lang="ts">
import { ref } from 'vue'
import { Listbox, Stack, Text } from '@hina-ui/vue'
const tags = ref(['school'])
const options = [
{ value: 'school', label: 'School' },
{ value: 'sf', label: 'Sci-fi' },
{ value: 'romance', label: 'Romance' },
{ value: 'mystery', label: 'Mystery' },
]
</script>
<template>
<Stack class="w-56">
<Listbox v-model="tags" :options="options" multiple aria-label="Tags" />
<Text tone="muted">Chosen: {{ tags.length ? tags.join(', ') : 'none' }}</Text>
</Stack>
</template>
Groups
A group carries a label and its options, and can be mixed with plain options.
<script setup lang="ts">
import { ref } from 'vue'
import { Listbox } from '@hina-ui/vue'
const sort = ref('latest')
const sorts = [
{ value: 'latest', label: 'Recently updated' },
{
label: 'By rating',
options: [
{ value: 'rating-desc', label: 'Rating, high to low' },
{ value: 'rating-asc', label: 'Rating, low to high' },
],
},
{
label: 'By release',
options: [
{ value: 'release-desc', label: 'Newest release' },
{ value: 'release-asc', label: 'Oldest release' },
],
},
]
</script>
<template>
<Listbox v-model="sort" :options="sorts" aria-label="Sort" class="w-56" />
</template>
Custom content
The option slot receives { option, selected } to customise each row.
The complete option type is inferred from options. Slot parameters preserve additional fields and their types; v-model still binds to option values. See Select for the type definitions.
<script setup lang="ts">
import { ref, type Component } from 'vue'
import { BookOpen, Clapperboard, Gamepad2 } from '@lucide/vue'
import { Inline, Listbox, Stack, Text } from '@hina-ui/vue'
const type = ref('gal')
const types = [
{ value: 'gal', label: 'Galgame', description: 'Visual novels and adventure games' },
{ value: 'ln', label: 'Light novel', description: 'Bunko volumes and web serials' },
{ value: 'anime', label: 'Anime', description: 'TV, films and OVAs' },
]
const icons: Record<string, Component> = { gal: Gamepad2, ln: BookOpen, anime: Clapperboard }
</script>
<template>
<Listbox v-model="type" :options="types" aria-label="Work type" class="w-64">
<template #option="{ option }">
<Inline as="span" gap="sm" :wrap="false">
<component :is="icons[option.value]" />
<Stack as="span" gap="none" class="min-w-0">
<Text as="span">{{ option.label }}</Text>
<Text as="span" size="xs" tone="muted">{{ option.description }}</Text>
</Stack>
</Inline>
</template>
</Listbox>
</template>
Trailing content
#trailing="{ option, selected }" replaces the entire trailing area. Without this slot the existing check indicator and its reserved space remain. When the slot is provided, an empty result intentionally removes the tail, including its gap; it does not restore the default indicator. Use <template #trailing /> to omit it for every row, or conditional content to omit it for individual options.
Custom trailing content determines its own width. The example uses selected to switch between a Tag and a check icon, and returns empty content for some options. The option and trailing slots receive the same selection state for grouped and plain options, single and multiple selection.
<script setup lang="ts">
import { ref } from 'vue'
import { Check } from '@lucide/vue'
import { Card, Listbox, Tag, Text } from '@hina-ui/vue'
const value = ref('a')
const options = [
{ count: 128, value: 'a', label: 'Option A' },
{
label: 'Group',
options: [
{ count: 2048, value: 'b', label: 'Option B' },
{ count: null, value: 'c', label: 'Option C' },
],
},
]
</script>
<template>
<Card :padded="false" class="w-64">
<Listbox
v-model="value"
:options="options"
variant="bare"
:padded="false"
aria-label="Custom trailing content"
>
<template #option="{ option, selected }">
<Text as="span" :weight="selected ? 'medium' : 'normal'" truncate>{{ option.label }}</Text>
</template>
<template #trailing="{ option, selected }">
<Text
v-if="option.count != null"
as="span"
size="xs"
tone="muted"
class="flex min-w-4 shrink-0 items-center justify-end"
>
<Check v-if="selected" aria-hidden="true" />
<Tag v-else tone="accent">{{ option.count }}</Tag>
</Text>
</template>
</Listbox>
</Card>
</template>
Custom trailing content does not change selection behaviour.
Scrolling
maxHeight caps the list, at 20rem by default; past it the list scrolls inside its box.
<script setup lang="ts">
import { ref } from 'vue'
import { Listbox } from '@hina-ui/vue'
const year = ref(2020)
const years = Array.from({ length: 30 }, (_, i) => ({ value: 1996 + i, label: `` }))
</script>
<template>
<Listbox v-model="year" :options="years" max-height="12rem" aria-label="Year" class="w-40" />
</template>
Variants
primary has a background, border and shadow; secondary has only a tinted background.
bare removes the root background, border, shadow and corner radius. Option styling and scrolling remain available.
<script setup lang="ts">
import { Listbox, Stack } from '@hina-ui/vue'
const options = [
{ value: 'a', label: 'Option A' },
{ value: 'b', label: 'Option B' },
]
</script>
<template>
<Stack class="w-56">
<Listbox :options="options" model-value="a" aria-label="Primary" />
<Listbox variant="secondary" :options="options" model-value="a" aria-label="Secondary" />
<Listbox variant="bare" :options="options" model-value="a" aria-label="Bare" />
</Stack>
</template>
Padding
padded defaults to true. Set it to false to remove the inner list’s 4px surrounding padding, independently of variant. Each option and group label keeps its own padding. class continues to style the root container.
<script setup lang="ts">
import { ref } from 'vue'
import { Card, Listbox, Stack, Switch } from '@hina-ui/vue'
const padded = ref(false)
const selected = ref('a')
const options = [
{ value: 'a', label: 'Option A' },
{ label: 'Group', options: [{ value: 'b', label: 'Option B' }] },
]
</script>
<template>
<Stack class="w-64">
<Switch v-model="padded" control-placement="end" block>List padding</Switch>
<Card :padded="false">
<Listbox
v-model="selected"
:options="options"
variant="bare"
:padded="padded"
aria-label="List padding"
/>
</Card>
</Stack>
</template>
States
disabled disables the whole list, and disabled on an option disables that option only.
<script setup lang="ts">
import { Listbox, Stack } from '@hina-ui/vue'
const options = [
{ value: 'gal', label: 'Galgame' },
{ value: 'ln', label: 'Light novel' },
{ value: 'manga', label: 'Manga', disabled: true },
]
</script>
<template>
<Stack class="w-56">
<Listbox :options="options" model-value="gal" aria-label="With a disabled option" />
<Listbox disabled :options="options" model-value="gal" aria-label="Disabled" />
</Stack>
</template>
In a form
Inside a FormField the label is linked to the list through aria-labelledby, and the error message is rendered by the field; validation rules and submission belong to the Form.
<script setup lang="ts">
import { reactive, ref } from 'vue'
import * as v from 'valibot'
import { Button, Form, FormField, Listbox, Text } from '@hina-ui/vue'
const plans = [
{ label: 'Free', value: 'free' },
{ label: 'Standard', value: 'standard' },
{ label: 'Pro', value: 'pro' },
]
const schema = v.object({
plan: v.string('Pick a plan'),
})
const values = reactive({
plan: null as string | number | null | Array<string | number>,
})
const saved = ref('')
async function save(data: unknown) {
await new Promise(resolve => setTimeout(resolve, 600))
saved.value = JSON.stringify(data)
}
</script>
<template>
<Form v-slot="{ submitting }" :values="values" :rules="schema" class="w-80" @submit="save">
<FormField name="plan" label="Plan" required>
<Listbox v-model="values.plan" :options="plans" />
</FormField>
<Button type="submit" :loading="submitting" class="self-start">Subscribe</Button>
<Text v-if="saved" tone="muted" size="sm">Subscribed: {{ saved }}</Text>
</Form>
</template>
Virtual scrolling
virtualize renders rows near the viewport, sharing measurement and scrolling with VirtualList. It is off by default. Pass { estimateSize, overscan } to configure estimated row height and the buffer on each side; actual heights are measured. Keyboard navigation covers the full collection and skips disabled items. Rows unmount outside the rendered range; keep persistent slot state outside the row, keyed by its unique value.
Selected: 7890
<script setup lang="ts">
import { ref } from 'vue'
import { Listbox, Stack, Text } from '@hina-ui/vue'
const selected = ref<string | number | null>(7890)
const options = Array.from({ length: 10000 }, (_, index) => ({
value: index,
label: `Item ${String(index + 1).padStart(5, '0')}`,
disabled: index % 97 === 0,
}))
</script>
<template>
<Stack gap="sm" class="w-56 max-w-full">
<Listbox
v-model="selected"
:options="options"
:virtualize="{ estimateSize: 36, overscan: 6 }"
aria-label="Ten thousand items"
/>
<Text size="sm" tone="muted">Selected: {{ selected }}</Text>
</Stack>
</template>
Behaviour
- Clicking toggles the choice. After tabbing into the list, the arrow keys move the highlight, Enter or Space chooses, and disabled options are skipped.
- Past its height the list scrolls inside its box, and the keyboard highlight scrolls with it.
Accessibility
- The list is
role="listbox", witharia-multiselectablewhen multiple; each option isrole="option"witharia-selected; groups arerole="group"linked to their label. - Name the list with
aria-labeloraria-labelledby.
API
Props
T extends SelectOption is inferred from options and defaults to SelectOption.
| Prop | Type | Default | Description |
|---|---|---|---|
modelValue | string | number | null | Array<string | number> | — | The chosen value, an array when multiple |
options | SelectItems<T> | — | The items, see Select |
virtualize | VirtualizeOptions | false | Virtual scrolling; content-based estimate, overscan 6 |
multiple | boolean | false | Whether several can be chosen |
maxHeight | string | '20rem' | Maximum height of the list |
padded | boolean | true | Keep the inner list’s surrounding padding |
variant | 'primary' | 'secondary' | 'bare' | 'primary' | Variant |
disabled | boolean | false | Whether the list is disabled |
class | string | — | Classes appended to the root element |
Slots
| Slot | Payload | Description |
|---|---|---|
option | { option: T; selected: boolean } | Content of each row |
trailing | { option: T; selected: boolean } | Entire trailing area; empty content removes its space |
Events
| Event | Payload | Description |
|---|---|---|
update:modelValue | value: string | number | Array<string | number> | The chosen value changed |
type VirtualizeOptions = boolean | { estimateSize?: number; overscan?: number }