MultiSelect

Picks several items from a list.

<script setup lang="ts">
  import { ref } from 'vue'
  import { MultiSelect } from '@hina-ui/vue'

  const tags = ref(['school', 'sf'])
  const options = [
    { value: 'school', label: 'School' },
    { value: 'sf', label: 'Sci-fi' },
    { value: 'romance', label: 'Romance' },
    { value: 'mystery', label: 'Mystery' },
    { value: 'fantasy', label: 'Fantasy' },
  ]
</script>

<template>
  <MultiSelect v-model="tags" :options="options" aria-label="Tags" class="w-72" />
</template>
vue

Usage

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

The multi-select shares its option data and list with Select, with v-model bound to an array of chosen values. Chosen items show as chips in the trigger, each removable on its own; with clearable set, a button at the end empties them all at once. The list stays open after a pick so several can be ticked in a row.

Current value: none

<script setup lang="ts">
  import { ref } from 'vue'
  import { MultiSelect, Stack, Text } from '@hina-ui/vue'

  const platforms = ref<string[]>([])
  const options = [
    { value: 'windows', label: 'Windows' },
    { value: 'switch', label: 'Switch' },
    { value: 'ps5', label: 'PS5' },
    { value: 'android', label: 'Android' },
    { value: 'ios', label: 'iOS' },
  ]
</script>

<template>
  <Stack class="w-72">
    <MultiSelect
      v-model="platforms"
      :options="options"
      placeholder="Choose platforms"
      aria-label="Platforms"
    />
    <Text tone="muted">Current value: {{ platforms.length ? platforms.join(', ') : 'none' }}</Text>
  </Stack>
</template>
vue

Examples

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 { MultiSelect } from '@hina-ui/vue'

  const languages = ref(['zh'])
  const options = [
    { value: 'zh', label: 'Simplified Chinese' },
    {
      label: 'Japanese',
      options: [
        { value: 'ja', label: 'Japanese original' },
        { value: 'ja-tl', label: 'Japanese with translation' },
      ],
    },
    {
      label: 'Others',
      options: [
        { value: 'en', label: 'English' },
        { value: 'ko', label: 'Korean' },
      ],
    },
  ]
</script>

<template>
  <MultiSelect v-model="languages" :options="options" aria-label="Languages" class="w-72" />
</template>
vue

Visible chips

The trigger keeps a fixed height, so only the first maxVisible chips show and the rest collapse into “+N”.

<script setup lang="ts">
  import { ref } from 'vue'
  import { MultiSelect, Stack } from '@hina-ui/vue'

  const tags = ref(['school', 'sf', 'romance', 'mystery'])
  const options = [
    { value: 'school', label: 'School' },
    { value: 'sf', label: 'Sci-fi' },
    { value: 'romance', label: 'Romance' },
    { value: 'mystery', label: 'Mystery' },
    { value: 'fantasy', label: 'Fantasy' },
  ]
</script>

<template>
  <Stack class="w-96">
    <MultiSelect v-model="tags" :options="options" :max-visible="1" aria-label="At most one" />
    <MultiSelect v-model="tags" :options="options" :max-visible="3" aria-label="At most three" />
  </Stack>
</template>
vue

Custom content

The option slot customises each row in the list.

The complete option type is inferred from options. Slot parameters preserve additional fields and their types; v-model still binds to an array of 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, MultiSelect } from '@hina-ui/vue'

  const types = ref(['gal'])
  const options = [
    { value: 'gal', label: 'Galgame' },
    { value: 'ln', label: 'Light novel' },
    { value: 'anime', label: 'Anime' },
  ]
  const icons: Record<string, Component> = { gal: Gamepad2, ln: BookOpen, anime: Clapperboard }
</script>

<template>
  <MultiSelect v-model="types" :options="options" aria-label="Work types" class="w-72">
    <template #option="{ option }">
      <Inline as="span" gap="sm" :wrap="false">
        <component :is="icons[option.value]" />
        {{ option.label }}
      </Inline>
    </template>
  </MultiSelect>
</template>
vue

Clearable

clearable adds a button at the end that empties everything. Each chip has its own remove button, so it is off by default.

<script setup lang="ts">
  import { ref } from 'vue'
  import { MultiSelect } from '@hina-ui/vue'

  const tags = ref(['school', 'sf'])
  const options = [
    { value: 'school', label: 'School' },
    { value: 'sf', label: 'Sci-fi' },
    { value: 'romance', label: 'Romance' },
    { value: 'mystery', label: 'Mystery' },
  ]
</script>

<template>
  <MultiSelect v-model="tags" :options="options" clearable aria-label="Tags" class="w-72" />
</template>
vue

Sizes

Three sizes, matching the input; the chips scale with them.

<script setup lang="ts">
  import { MultiSelect, Stack } from '@hina-ui/vue'

  const options = [
    { value: 'school', label: 'School' },
    { value: 'sf', label: 'Sci-fi' },
    { value: 'romance', label: 'Romance' },
  ]
</script>

<template>
  <Stack class="w-72">
    <MultiSelect size="sm" :options="options" :model-value="['school', 'sf']" aria-label="Small" />
    <MultiSelect size="md" :options="options" :model-value="['school', 'sf']" aria-label="Medium" />
    <MultiSelect size="lg" :options="options" :model-value="['school', 'sf']" aria-label="Large" />
  </Stack>
</template>
vue

Variants

primary sits directly on the page background with a border and shadow; secondary sits inside a surface such as a card, with only a tinted fill.

<script setup lang="ts">
  import { Card, MultiSelect, Stack } from '@hina-ui/vue'

  const options = [
    { value: 'school', label: 'School' },
    { value: 'sf', label: 'Sci-fi' },
  ]
</script>

<template>
  <Stack class="w-72">
    <MultiSelect :options="options" placeholder="Directly on the page" aria-label="On the page" />
    <Card>
      <MultiSelect
        variant="secondary"
        :options="options"
        placeholder="Inside a card"
        aria-label="Inside a card"
      />
    </Card>
  </Stack>
</template>
vue

States

invalid marks a failed validation and disabled disables the whole control.

<script setup lang="ts">
  import { MultiSelect, Stack } from '@hina-ui/vue'

  const options = [
    { value: 'school', label: 'School' },
    { value: 'sf', label: 'Sci-fi' },
    { value: 'romance', label: 'Romance' },
  ]
</script>

<template>
  <Stack class="w-72">
    <MultiSelect
      invalid
      :options="options"
      placeholder="Choose at least one tag"
      aria-label="Invalid"
    />
    <MultiSelect disabled :options="options" :model-value="['school']" aria-label="Disabled" />
  </Stack>
</template>
vue

In a form

Inside a FormField the label points at the trigger, and the description and error message are rendered by the field; validation rules and submission belong to the Form. The value is an array, so count limits go on the array.

One to three of them

<script setup lang="ts">
  import { reactive, ref } from 'vue'
  import * as v from 'valibot'
  import { Button, Form, FormField, MultiSelect, Text } from '@hina-ui/vue'

  const genres = [
    { label: 'Romance', value: 'romance' },
    { label: 'Mystery', value: 'mystery' },
    { label: 'Fantasy', value: 'fantasy' },
    { label: 'Slice of life', value: 'slice-of-life' },
    { label: 'Science fiction', value: 'sci-fi' },
  ]

  const schema = v.object({
    genres: v.pipe(
      v.array(v.string('Pick genres')),
      v.minLength(1, 'Pick at least one genre'),
      v.maxLength(3, 'Pick at most three genres'),
    ),
  })

  const values = reactive({ genres: [] as 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="genres" label="Genres" description="One to three of them" required>
      <MultiSelect v-model="values.genres" :options="genres" />
    </FormField>
    <Button type="submit" :loading="submitting" class="self-start">Save</Button>
    <Text v-if="saved" tone="muted" size="sm">Saved: {{ 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: 1

<script setup lang="ts">
  import { ref } from 'vue'
  import { MultiSelect, Stack, Text } from '@hina-ui/vue'

  const selected = ref<Array<string | number>>([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-72 max-w-full">
    <MultiSelect
      v-model="selected"
      :options="options"
      :virtualize="{ estimateSize: 36, overscan: 6 }"
      aria-label="Ten thousand items"
    />
    <Text size="sm" tone="muted">Selected: {{ selected.length }}</Text>
  </Stack>
</template>
vue

Behaviour

  • Clicking or pressing Enter on an option ticks it and keeps the list open; doing it again unticks it.
  • The remove button on a chip and the clear button at the end do not open the list.
  • The clear button transitions in and out.
  • The list opens against the trigger at the same width, and scrolls inside the panel when it is taller than the space available.
  • Page scrolling is locked while open; clicking outside or pressing Esc closes it.

Accessibility

  • The trigger is role="combobox", the list is role="listbox" and each option is role="option" with aria-selected.
  • The remove and clear buttons are named from the locale pack and reachable with Tab.
  • Pair it with a label element or an aria-label for its name. invalid also sets aria-invalid.

API

Props

T extends SelectOption is inferred from options and defaults to SelectOption.

Prop
Type
Default
Description
modelValue
Array<string | number>
[]
The chosen values
options
SelectItems<T>
—
The items, see Select
virtualize
VirtualizeOptions
false
Virtual scrolling; content-based estimate, overscan 6
placeholder
string
locale pack
Text shown when nothing is chosen
maxVisible
number
2
Maximum chips shown in the trigger
clearable
boolean
false
Whether the clear-all button is shown
open
boolean
false
Whether the list is open; v-model:open
variant
'primary' | 'secondary'
'primary'
Variant
size
'sm' | 'md' | 'lg'
'md'
Size
invalid
boolean
false
Whether validation failed
name
string
—
Native form field name
required
boolean
false
Require a selection when name is set
autocomplete
string
—
Native form autofill hint
disabled
boolean
false
Whether the control is disabled
class
string
—
Classes appended to the trigger

Slots

Slot
Payload
Description
option
{ option: T }
Content of each row in the list

Events

Event
Payload
Description
update:modelValue
value: Array<string | number>
The chosen values changed
update:open
open: boolean
The list opened or closed
clear
—
Everything was cleared
type VirtualizeOptions = boolean | { estimateSize?: number; overscan?: number }
ts