<script setup lang="ts">
import { ref } from 'vue'
import { TreeSelect } from '@hina-ui/vue'
import { regions } from './data'
const region = ref<string | null>('kyoto')
</script>
<template>
<TreeSelect v-model="region" :items="regions" aria-label="Region" class="w-64" />
</template>
Usage
import { TreeSelect } from '@hina-ui/vue'
The tree select shares its trigger with Select and opens a tree that expands level by level. items supplies the nodes, each { value, label }, and a node with children can be expanded; v-model binds the chosen node’s value, and a node at any level can be chosen.
Current value: none
<script setup lang="ts">
import { ref } from 'vue'
import { Stack, Text, TreeSelect } from '@hina-ui/vue'
import { regions } from './data'
const region = ref<string | null>(null)
</script>
<template>
<Stack class="w-64">
<TreeSelect
v-model="region"
:items="regions"
placeholder="Choose a region"
aria-label="Region"
/>
<Text tone="muted">Current value: {{ region ?? 'none' }}</Text>
</Stack>
</template>
Examples
Expanded by default
defaultExpanded lists the nodes expanded when the tree opens. The path to the chosen node is always expanded as well.
<script setup lang="ts">
import { ref } from 'vue'
import { TreeSelect } from '@hina-ui/vue'
import { regions } from './data'
const region = ref<string | null>(null)
</script>
<template>
<TreeSelect
v-model="region"
:items="regions"
:default-expanded="['jp', 'kanto']"
placeholder="Choose a region"
aria-label="Region"
class="w-64"
/>
</template>
Search
searchable adds a search field above the tree. Filtering matches node label values without case or accent differences. Matching nodes keep their ancestor paths, which expand automatically; unrelated nodes are hidden. Clearing search restores the previous expansion state and leaves the selection unchanged.
Bind the query with v-model:search and set the search field text with searchPlaceholder. Closing the panel clears the query. From the search field, Down or Up focuses the first or last enabled node. Up on the first node returns to search. Esc clears a non-empty query first, then closes the panel on the next press.
Value: None
Search: None
<script setup lang="ts">
import { ref } from 'vue'
import { Stack, Text, TreeSelect } from '@hina-ui/vue'
import { regions } from './data'
const region = ref<string | null>(null)
const search = ref('')
</script>
<template>
<Stack class="w-64">
<TreeSelect
v-model="region"
v-model:search="search"
:items="regions"
searchable
search-placeholder="Search nodes"
aria-label="Region"
/>
<Text tone="muted">Value: {{ region ?? 'None' }}</Text>
<Text tone="muted">Search: {{ search || 'None' }}</Text>
</Stack>
</template>
Custom content
The node slot customises each node.
<script setup lang="ts">
import { ref } from 'vue'
import { Inline, Text, TreeSelect, type TreeSelectNode } from '@hina-ui/vue'
const dept = ref<string | null>(null)
const departments: TreeSelectNode[] = [
{
value: 'product',
label: 'Product',
description: '12 people',
children: [
{ value: 'design', label: 'Design', description: '5 people' },
{ value: 'research', label: 'Research', description: '3 people' },
],
},
{
value: 'engineering',
label: 'Engineering',
description: '28 people',
children: [
{ value: 'web', label: 'Web', description: '9 people' },
{ value: 'api', label: 'API', description: '11 people' },
],
},
]
</script>
<template>
<TreeSelect
v-model="dept"
:items="departments"
placeholder="Choose a department"
aria-label="Department"
class="w-72"
>
<template #node="{ node }">
<Inline as="span" gap="sm" :wrap="false" justify="between">
<Text as="span">{{ node.label }}</Text>
<Text as="span" size="xs" tone="muted">{{ node.description }}</Text>
</Inline>
</template>
</TreeSelect>
</template>
Sizes
Three sizes, matching the input.
<script setup lang="ts">
import { Stack, TreeSelect } from '@hina-ui/vue'
import { regions } from './data'
</script>
<template>
<Stack class="w-64">
<TreeSelect size="sm" :items="regions" model-value="tokyo" aria-label="Small" />
<TreeSelect size="md" :items="regions" model-value="tokyo" aria-label="Medium" />
<TreeSelect size="lg" :items="regions" model-value="tokyo" 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, Stack, TreeSelect } from '@hina-ui/vue'
import { regions } from './data'
</script>
<template>
<Stack class="w-64">
<TreeSelect :items="regions" placeholder="Directly on the page" aria-label="On the page" />
<Card>
<TreeSelect
variant="secondary"
:items="regions"
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 a node disables that node only.
<script setup lang="ts">
import { Stack, TreeSelect, type TreeSelectNode } from '@hina-ui/vue'
const items: TreeSelectNode[] = [
{
value: 'jp',
label: 'Japan',
children: [
{ value: 'tokyo', label: 'Tokyo' },
{ value: 'osaka', label: 'Osaka', disabled: true },
],
},
]
</script>
<template>
<Stack class="w-64">
<TreeSelect invalid :items="items" placeholder="Choose a region" aria-label="Invalid" />
<TreeSelect disabled :items="items" model-value="tokyo" aria-label="Disabled" />
<TreeSelect
:items="items"
:default-expanded="['jp']"
placeholder="With a disabled node"
aria-label="With a disabled node"
/>
</Stack>
</template>
In a form
Inside a FormField the label points at the trigger, and the error message is rendered by the field and linked to it; 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, Text, TreeSelect } from '@hina-ui/vue'
import { regions } from './data'
const schema = v.object({
region: v.string('Pick a region'),
})
const values = reactive({ region: 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="region" label="Region" required>
<TreeSelect v-model="values.region" :items="regions" />
</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. Search still processes the full dataset. Only expanded, visible nodes participate in the window; parent-child navigation and selection do not depend on mounted rows. 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 { TreeSelect, 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,
}))
const items = [{ value: 'root', label: 'All nodes', children: options }]
</script>
<template>
<Stack gap="sm" class="w-64 max-w-full">
<TreeSelect
v-model="selected"
:items="items"
:default-expanded="['root']"
:virtualize="{ estimateSize: 36, overscan: 6 }"
searchable
aria-label="Ten thousand items"
/>
<Text size="sm" tone="muted">Selected: {{ selected }}</Text>
</Stack>
</template>
Behaviour
- Clicking the arrow before a node only expands or collapses it; clicking the node itself chooses it and closes the panel.
- Up and Down move focus, Right expands, Left collapses, and Enter or Space chooses.
- The panel opens against the trigger at the same width, and scrolls inside when the tree 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 tree isrole="tree"and each node isrole="treeitem"witharia-level,aria-expandedandaria-selected. - Pair it with a
labelelement or anaria-labelfor its name.invalidalso setsaria-invalid.
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
modelValue | string | number | null | — | Value of the chosen node |
items | TreeSelectNode[] | — | The nodes, see the type below |
virtualize | VirtualizeOptions | false | Virtual scrolling; content-based estimate, overscan 6 |
placeholder | string | locale pack | Text shown when nothing is chosen |
searchable | boolean | false | Show search and filter nodes |
search | string | '' | Query; supports v-model:search |
searchPlaceholder | string | locale pack | Search placeholder and accessible name |
defaultExpanded | Array<string | number> | [] | Nodes expanded when the panel opens |
open | boolean | false | Whether the panel is open; v-model:open |
variant | 'primary' | 'secondary' | 'primary' | Variant |
size | 'sm' | 'md' | 'lg' | 'md' | Size |
invalid | boolean | false | Whether validation failed |
disabled | boolean | false | Whether the select is disabled |
class | string | — | Classes appended to the trigger |
Slots
| Slot | Payload | Description |
|---|---|---|
node | { node: TreeSelectNode } | Content of each node |
Events
| Event | Payload | Description |
|---|---|---|
update:modelValue | value: string | number | The chosen value changed |
update:open | open: boolean | The panel opened or closed |
update:search | search: string | The search query changed |
Types
interface TreeSelectNode {
value: string | number
label: string
description?: string
disabled?: boolean
children?: TreeSelectNode[]
}
type VirtualizeOptions = boolean | { estimateSize?: number; overscan?: number }