<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>
Usage
import { Select } from '@hina-ui/vue'
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>
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>
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>
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>
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>
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>
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>
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>
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>
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 isrole="listbox"and each option isrole="option"witharia-selected. - Pair it with a
labelelement or anaria-labelfor its name.invalidalso setsaria-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>>
type VirtualizeOptions = boolean | { estimateSize?: number; overscan?: number }