Tree

Keyboard-friendly hierarchical navigation and selection.
  • Components
  • Button
  • Card
vue
<script setup lang="ts">
import { Icon } from '@iconify/vue'
import { TreeItem, TreeRoot } from '@typlog/ui'

interface Node {
  id: string
  name: string
  icon: string
  children?: Node[]
}

const items: Node[] = [
  {
    id: 'components',
    name: 'Components',
    icon: 'lucide:folder',
    children: [
      { id: 'button', name: 'Button', icon: 'lucide:box' },
      { id: 'card', name: 'Card', icon: 'lucide:box' },
    ],
  },
  {
    id: 'docs',
    name: 'Documentation',
    icon: 'lucide:folder',
    children: [
      { id: 'getting-started', name: 'Getting started', icon: 'lucide:file-text' },
      { id: 'theming', name: 'Theming', icon: 'lucide:file-text' },
    ],
  },
]
</script>

<template>
  <TreeRoot
    :items="items"
    :get-key="item => item.id"
    :get-children="item => item.children"
    :default-expanded="['components']"
    class="max-w-sm"
  >
    <template #default="{ item }">
      <TreeItem
        v-bind="item.bind"
        :text="item.value.name"
        :icon="item.value.icon"
      >
        <template #icon="{ isExpanded }">
          <Icon
            :icon="item.value.children ? (isExpanded ? 'lucide:folder-open' : 'lucide:folder') : item.value.icon"
            aria-hidden="true"
          />
        </template>
      </TreeItem>
    </template>
  </TreeRoot>
</template>

TreeRoot delegates hierarchy, selection, expansion, and keyboard navigation to Reka UI. It does not manage application routes or a filesystem; provide your own item data and render each flattened item with TreeItem. The default slot receives { item, virtualItem? }; bind item.bind to preserve its generated aria-setsize, aria-posinset, value, and level metadata. virtualItem is available only when virtual rendering is enabled.

The default presentation is a compact, bordered file-tree panel: item names use a monospace face, nested levels receive subtle guide lines, and hover/selection backgrounds are limited to the inline target area. Use the trailing slot for muted, non-interactive context such as file comments. The indicator is purely visual; clicking anywhere in the target, including the chevron, follows Reka's native row selection and expansion behavior.

API Reference ​

TreeRoot ​

PropDefaultType
color
–

Overrides the accent color inherited from the theme.

highContrastfalse
boolean

Uses a higher contrast color for selected tree items.

size"2"
"1""2""3"

Controls the size of each tree row.

virtualfalse
TreeVirtualOptions<T>boolean

Enables virtual rendering, optionally configuring the virtualizer.

TreeItem ​

PropDefaultType
hasChildren
–
boolean

Whether the item should display a disclosure indicator. Defaults to the tree data.

icon
–
string

Iconify icon rendered before the item text.

text
–
string

Text rendered when no text or default slot is provided.

trailingIcon
–
string

Iconify icon rendered at the end of the row.

TreeItemIndicator ​

PropDefaultType
disabledfalse
boolean

Whether the item is disabled.

expandedfalse
boolean

Whether the parent item is expanded.

hasChildrenfalse
boolean

Whether the item has children.

Examples ​

File tree ​

Tree can present file-like data, but file and folder behavior remains the responsibility of the application. The file-tree example keeps children: [] for the empty archive folder, so it remains a disclosure branch; return undefined from getChildren when an item should be a leaf.

  • srcsource
  • index.tsentrypoint
  • README.mdstart here
  • a-very-long-file-name-that-stays-within-the-row-boundary.tstruncated when needed
vue
<script setup lang="ts">
import { Icon } from '@iconify/vue'
import { TreeItem, TreeRoot } from '@typlog/ui'

interface FileNode {
  id: string
  name: string
  type: 'folder' | 'file'
  comment?: string
  children?: FileNode[]
}

const items: FileNode[] = [
  {
    id: 'src',
    name: 'src',
    type: 'folder',
    comment: 'source',
    children: [
      {
        id: 'components',
        name: 'components',
        type: 'folder',
        comment: 'UI primitives',
        children: [
          { id: 'button', name: 'Button.vue', type: 'file', comment: 'actions' },
          { id: 'tree', name: 'TreeItemIndicator.vue', type: 'file', comment: 'disclosure' },
        ],
      },
      { id: 'index', name: 'index.ts', type: 'file', comment: 'entrypoint' },
    ],
  },
  {
    id: 'docs',
    name: 'docs',
    type: 'folder',
    comment: 'documentation',
    children: [
      { id: 'empty', name: 'empty-state.md', type: 'file', comment: 'content' },
      { id: 'archive', name: 'archive', type: 'folder', children: [] },
    ],
  },
  { id: 'readme', name: 'README.md', type: 'file', comment: 'start here' },
  {
    id: 'long',
    name: 'a-very-long-file-name-that-stays-within-the-row-boundary.ts',
    type: 'file',
    comment: 'truncated when needed',
  },
]

const getIcon = (item: FileNode, isExpanded = false) => {
  if (item.type === 'folder')
    return isExpanded ? 'lucide:folder-open' : 'lucide:folder'
  if (item.name.endsWith('.vue')) return 'lucide:file-code-2'
  if (item.name.endsWith('.ts')) return 'lucide:file-type-2'
  if (item.name.endsWith('.md')) return 'lucide:file-text'
  return 'lucide:file'
}
</script>

<template>
  <TreeRoot
    :items="items"
    :get-key="item => item.id"
    :get-children="item => item.children"
    :default-expanded="['src']"
    class="max-w-xl"
  >
    <template #default="{ item }">
      <TreeItem
        v-bind="item.bind"
        :text="item.value.name"
      >
        <template #icon="{ isExpanded }">
          <Icon
            :icon="getIcon(item.value, isExpanded)"
            aria-hidden="true"
          />
        </template>
        <template v-if="item.value.comment" #trailing>
          {{ item.value.comment }}
        </template>
      </TreeItem>
    </template>
  </TreeRoot>
</template>

Size ​

  • Compact tree
  • A child item
  • Compact tree
  • A child item
  • Compact tree
  • A child item
vue
<script setup lang="ts">
import { TreeItem, TreeRoot } from '@typlog/ui'

interface Node {
  id: string
  name: string
  children?: Node[]
}

const items: Node[] = [
  { id: 'one', name: 'Compact tree', children: [{ id: 'one-child', name: 'A child item' }] },
  { id: 'two', name: 'Another item', children: [{ id: 'two-child', name: 'A nested item' }] },
]
const sizes = ['1', '2', '3'] as const
</script>

<template>
  <div class="flex max-w-md flex-col gap-4">
    <TreeRoot
      v-for="size in sizes"
      :key="size"
      :items="items"
      :get-key="item => item.id"
      :get-children="item => item.children"
      :size="size"
      :default-expanded="['one']"
    >
      <template #default="{ item }">
        <TreeItem
          v-bind="item.bind"
          :text="item.value.name"
        />
      </template>
    </TreeRoot>
  </div>
</template>

Color ​

  • Overview
  • Summary
  • Overview
  • Summary
  • Overview
  • Summary
  • Overview
  • Summary
  • Overview
  • Summary
  • Overview
  • Summary
vue
<script setup lang="ts">
import { TreeItem, TreeRoot } from '@typlog/ui'

interface Node {
  id: string
  name: string
  children?: Node[]
}

const items: Node[] = [
  { id: 'overview', name: 'Overview', children: [{ id: 'summary', name: 'Summary' }] },
  { id: 'settings', name: 'Settings', children: [{ id: 'profile', name: 'Profile' }] },
]
const colors = ['indigo', 'blue', 'green', 'amber', 'crimson', 'violet'] as const
</script>

<template>
  <div class="flex max-w-md flex-col gap-4">
    <TreeRoot
      v-for="color in colors"
      :key="color"
      :items="items"
      :get-key="item => item.id"
      :get-children="item => item.children"
      :color="color"
      :default-value="items[0]"
      :default-expanded="['overview']"
    >
      <template #default="{ item }">
        <TreeItem
          v-bind="item.bind"
          :text="item.value.name"
        />
      </template>
    </TreeRoot>
  </div>
</template>

Selection ​

The root supports controlled single selection with v-model.

  • Inbox
  • Archive
  • Drafts

Selected: Nothing selected

vue
<script setup lang="ts">
import { computed, ref } from 'vue'
import { TreeItem, TreeRoot } from '@typlog/ui'

interface Node {
  id: string
  name: string
  children?: Node[]
}

const items: Node[] = [
  { id: 'inbox', name: 'Inbox' },
  { id: 'archive', name: 'Archive' },
  { id: 'drafts', name: 'Drafts' },
]
const selected = ref<Node>()
const selectedLabel = computed(() => selected.value?.name ?? 'Nothing selected')
</script>

<template>
  <div class="flex max-w-sm flex-col gap-3">
    <TreeRoot
      v-model="selected"
      :items="items"
      :get-key="item => item.id"
      selection-behavior="replace"
    >
      <template #default="{ item }">
        <TreeItem
          v-bind="item.bind"
          :text="item.value.name"
        />
      </template>
    </TreeRoot>
    <p class="text-sm text-gray-11">Selected: {{ selectedLabel }}</p>
  </div>
</template>

Multiple selection ​

Set multiple to allow more than one selected item.

  • Design
  • Tokens
  • Patterns
  • Engineering
  • Frontend
  • Backend

Selected: Nothing selected

vue
<script setup lang="ts">
import { computed, ref } from 'vue'
import { TreeItem, TreeRoot } from '@typlog/ui'

interface Node {
  id: string
  name: string
  children?: Node[]
}

const items: Node[] = [
  { id: 'design', name: 'Design', children: [{ id: 'tokens', name: 'Tokens' }, { id: 'patterns', name: 'Patterns' }] },
  { id: 'engineering', name: 'Engineering', children: [{ id: 'frontend', name: 'Frontend' }, { id: 'backend', name: 'Backend' }] },
]
const selected = ref<Node[]>([])
const selectedLabel = computed(() => selected.value.length ? selected.value.map(item => item.name).join(', ') : 'Nothing selected')
</script>

<template>
  <div class="flex max-w-sm flex-col gap-3">
    <TreeRoot
      v-model="selected"
      multiple
      :items="items"
      :get-key="item => item.id"
      :get-children="item => item.children"
      :default-expanded="['design', 'engineering']"
    >
      <template #default="{ item }">
        <TreeItem
          v-bind="item.bind"
          :text="item.value.name"
        />
      </template>
    </TreeRoot>
    <p class="break-words text-sm text-gray-11">Selected: {{ selectedLabel }}</p>
  </div>
</template>

Disabled items ​

Disable individual items or the complete tree with the disabled prop.

  • Available item
  • Disabled item
  • Another item
vue
<script setup lang="ts">
import { TreeItem, TreeRoot } from '@typlog/ui'

const items = [
  { id: 'available', name: 'Available item' },
  { id: 'disabled', name: 'Disabled item' },
  { id: 'other', name: 'Another item' },
]
</script>

<template>
  <TreeRoot
    :items="items"
    :get-key="item => item.id"
    class="max-w-sm"
  >
    <template #default="{ item }">
      <TreeItem
        v-bind="item.bind"
        :text="item.value.name"
        :disabled="item.value.id === 'disabled'"
      />
    </template>
  </TreeRoot>
</template>

Virtualized tree ​

Set virtual to true or pass virtualizer options to TreeRoot to render only visible flattened items. The root itself must be the scrolling container with an explicit height; an outer wrapper is not sufficient. estimateSize defaults to 28, and overscan defaults to 12.

vue
<script setup lang="ts">
import { TreeItem, TreeRoot } from '@typlog/ui'

interface Node {
  id: string
  name: string
}

const items: Node[] = Array.from({ length: 100 }, (_, index) => ({
  id: `item-${index}`,
  name: `Virtual item ${index + 1}`,
}))
</script>

<template>
  <TreeRoot
    class="h-64 max-w-sm overflow-auto rounded-md border border-gray-6 p-2"
    :items="items"
    :get-key="item => item.id"
    :virtual="{
      estimateSize: 32,
      textContent: item => item.name,
    }"
  >
    <template #default="{ item }">
      <TreeItem
        v-bind="item.bind"
        :text="item.value.name"
      />
    </template>
  </TreeRoot>
</template>
Last updated Sep 15, 2026