Listbox

An always-visible list of options.

Want to read
Reading
Finished
Dropped
<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>
vue

Usage

import { Listbox } from '@hina-ui/vue'
ts

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.

Galgame
Light novel
Manga
Anime

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>
vue

Examples

Multiple

With multiple set several options can be chosen at once; clicking again unticks.

School
Sci-fi
Romance
Mystery

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>
vue

Groups

A group carries a label and its options, and can be mixed with plain options.

Recently updated
By rating
Rating, high to low
Rating, low to high
By release
Newest release
Oldest release
<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>
vue

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.

GalgameVisual novels and adventure games
Light novelBunko volumes and web serials
AnimeTV, films and OVAs
<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>
vue

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.

Option A
Group
Option B2048
Option C
<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>
vue

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>
vue

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.

Option A
Option B
Option A
Option B
Option A
Option B
<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>
vue

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.

Option A
Group
Option B
<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>
vue

States

disabled disables the whole list, and disabled on an option disables that option only.

Galgame
Light novel
Manga
Galgame
Light novel
Manga
<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>
vue

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.

Free
Standard
Pro
<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>
vue

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>
vue

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", with aria-multiselectable when multiple; each option is role="option" with aria-selected; groups are role="group" linked to their label.
  • Name the list with aria-label or aria-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 }
ts