Select

Picks one item from a list.

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

  const type = ref('gal')
  const types = [
    { value: 'gal', label: 'Galgame' },
    { value: 'ln', label: 'Light novel' },
    { value: 'manga', label: 'Manga' },
    { value: 'anime', label: 'Anime' },
  ]
</script>

<template>
  <Select v-model="type" :options="types" aria-label="Work type" class="w-64" />
</template>
vue

Usage

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

The select pairs a trigger with a floating list. options supplies the items and v-model binds the chosen value. Each option is { value, label }, optionally with description and disabled; an item with options and no value is a group. The trigger shares the input’s field surface.

Current value: none

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

  const status = ref<string | null>(null)
  const statuses = [
    { value: 'wish', label: 'Want to read' },
    { value: 'doing', label: 'Reading' },
    { value: 'done', label: 'Finished' },
    { value: 'dropped', label: 'Dropped' },
  ]
</script>

<template>
  <Stack class="w-56">
    <Select
      v-model="status"
      :options="statuses"
      placeholder="Choose a status"
      aria-label="Collection status"
    />
    <Text tone="muted">Current value: {{ status ?? 'none' }}</Text>
  </Stack>
</template>
vue

Examples

Clear

clearable shows a clear button when a value is set and the field is enabled. Activating it sets v-model to null and emits clear. Focus returns to the trigger and the list stays closed.

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

  const work = ref<string | null>('clannad')
  const works = [
    { value: 'summer-pockets', label: 'Summer Pockets' },
    { value: 'clannad', label: 'CLANNAD' },
    { value: 'rewrite', label: 'Rewrite' },
  ]
</script>

<template>
  <Select v-model="work" :options="works" clearable aria-label="Work" class="w-72" />
</template>
vue

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 { Select } 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>
  <Select v-model="sort" :options="sorts" aria-label="Sort" class="w-56" />
</template>
vue

Custom content

The option slot customises each row in the list and the value slot customises what the trigger shows; both receive the current option.

The complete option type is inferred from options. Slot parameters preserve additional fields and their types; v-model still binds to value. Use SelectOption<{ icon: Component }> to declare extra fields; the example reads the icon directly from option.icon.

<script setup lang="ts">
  import { ref, type Component } from 'vue'
  import { BookOpen, Clapperboard, Gamepad2 } from '@lucide/vue'
  import { Inline, Select, Stack, Text, type SelectOption } from '@hina-ui/vue'

  const type = ref('gal')
  const types: SelectOption<{ icon: Component }>[] = [
    {
      icon: Gamepad2,
      value: 'gal',
      label: 'Galgame',
      description: 'Visual novels and adventure games',
    },
    {
      icon: BookOpen,
      value: 'ln',
      label: 'Light novel',
      description: 'Bunko volumes and web serials',
    },
    { icon: Clapperboard, value: 'anime', label: 'Anime', description: 'TV, films and OVAs' },
  ]
</script>

<template>
  <Select v-model="type" :options="types" aria-label="Work type" class="w-64">
    <template #value="{ option }">
      <Inline as="span" gap="sm" :wrap="false">
        <component :is="option.icon" />
        {{ option.label }}
      </Inline>
    </template>
    <template #option="{ option }">
      <Inline as="span" gap="sm" :wrap="false">
        <component :is="option.icon" />
        <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>
  </Select>
</template>
vue

Sizes

Three sizes, matching the input.

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

  const options = [
    { value: 'gal', label: 'Galgame' },
    { value: 'ln', label: 'Light novel' },
  ]
</script>

<template>
  <Stack class="w-56">
    <Select size="sm" :options="options" model-value="gal" aria-label="Small" />
    <Select size="md" :options="options" model-value="gal" aria-label="Medium" />
    <Select size="lg" :options="options" model-value="gal" 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, Select, Stack } from '@hina-ui/vue'

  const options = [
    { value: 'gal', label: 'Galgame' },
    { value: 'ln', label: 'Light novel' },
  ]
</script>

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

States

invalid marks a failed validation, disabled disables the whole select, and disabled on an option disables that option only. An empty list shows a hint.

<script setup lang="ts">
  import { Select, 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">
    <Select invalid :options="options" placeholder="Choose a type" aria-label="Invalid" />
    <Select disabled :options="options" model-value="gal" aria-label="Disabled" />
    <Select :options="[]" placeholder="Nothing to choose" aria-label="Empty list" />
  </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 and linked to it; validation rules, timing and submission belong to the Form.

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

  const categories = [
    { label: 'Game', value: 'game' },
    { label: 'Novel', value: 'novel' },
    { label: 'Manga', value: 'manga' },
  ]

  const schema = v.object({
    category: v.pipe(v.string('Pick a category'), v.nonEmpty('Pick a category')),
  })

  const values = reactive({ category: null as string | number | null })
  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="category" label="Category" required>
      <Select v-model="values.category" :options="categories" />
    </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: 7890

<script setup lang="ts">
  import { ref } from 'vue'
  import { Select, 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">
    <Select
      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

  • 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.
  • Arrow keys, Home, End and typing the first letters move the highlight.
  • Choosing an option closes the list and returns focus to the trigger.

Accessibility

  • The trigger is role="combobox", the list is role="listbox" and each option is role="option" with aria-selected.
  • 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
string | number | null
—
The chosen value
options
SelectItems<T>
—
The items, see the types below
virtualize
VirtualizeOptions
false
Virtual scrolling; content-based estimate, overscan 6
clearable
boolean
false
Whether the clear button is shown
placeholder
string
locale pack
Text shown when nothing is chosen
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 select is disabled
class
string
—
Classes appended to the root element

Slots

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

Events

Event
Payload
Description
update:modelValue
value: string | number | null
The chosen value changed
update:open
open: boolean
The list opened or closed
clear
—
The selection was cleared

Types

type SelectOption<T extends object = object> = {
  value: string | number
  label: string
  description?: string
  disabled?: boolean
} & T

interface SelectOptionGroup<T extends SelectOption = SelectOption> {
  label: string
  options: T[]
}

type SelectItems<T extends SelectOption = SelectOption> = Array<T | SelectOptionGroup<T>>
ts
type VirtualizeOptions = boolean | { estimateSize?: number; overscan?: number }
ts