<script setup lang="ts">
import { TreeView, TreeViewIndicator, TreeViewItem } from "@/components/tree-view";
import { FileIcon } from "lucide-vue-next";
import { ref } from "vue";
import { TypeScriptIcon, ViteIcon, VueDotjsIcon } from "vue3-simple-icons";
type TreeNode = {
id: string;
title: string;
children?: TreeNode[];
};
const items: TreeNode[] = [
{
id: "src",
title: "src",
children: [
{
id: "components",
title: "components",
children: [
{
id: "ui",
title: "ui",
children: [
{ id: "button", title: "Button.vue" },
{ id: "input", title: "Input.vue" },
{ id: "select", title: "Select.vue" },
],
},
],
},
{
id: "pages",
title: "pages",
children: [
{ id: "home", title: "Home.vue" },
{ id: "about", title: "About.vue" },
{ id: "contact", title: "Contact.vue" },
],
},
{
id: "App.vue",
title: "App.vue",
},
{
id: "main.ts",
title: "main.ts",
},
],
},
{
id: "vite.config.ts",
title: "vite.config.ts",
},
];
function getFileIcon(filename: string) {
if (filename.endsWith(".vue")) {
return h(VueDotjsIcon, { class: "size-4 text-[#4FC08D]" });
}
if (filename === "vite.config.ts") {
return h(ViteIcon, { class: "size-4 text-[#9135FF]" });
}
if (filename.endsWith(".ts")) {
return h(TypeScriptIcon, { class: "size-4 text-[#3178C6]" });
}
return h(FileIcon, { class: "size-4" });
}
const expanded = ref(["documents", "vuetify", "src", "material", "material-src"]);
</script>
<template>
<TreeView
v-slot="{ flattenItems }"
v-model:expanded="expanded"
guideline="straight"
:items
:get-key="(item) => item.id"
class="w-full max-w-sm rounded-lg border p-2"
>
<TreeViewItem
v-for="item in flattenItems"
:key="item._id"
v-slot="slotProps"
v-bind="item.bind"
class="flex min-h-8 items-center gap-2 rounded-sm px-2 text-xs outline-none hover:bg-accent/30 data-selected:bg-accent cursor-default select-none"
>
<TreeViewIndicator
v-bind="slotProps"
:has-children="item.hasChildren"
class="inline-flex size-4 shrink-0 items-center justify-center text-muted-foreground"
/>
<component v-if="item.hasChildren === false" :is="getFileIcon(item.value.title)" />
{{ item.value.title }}
</TreeViewItem>
</TreeView>
</template>
Features
- Reka UI Tree — Built on the alpha Tree primitive (
TreeRoot,TreeItem,TreeVirtualizer) - Composable parts — Style each row with
TreeViewItem, optionalTreeViewIndicator, and your own label content - Guideline variants — Connect branches with
guideline="straight"orguideline="rounded"onTreeView - Virtualization — Use
TreeViewVirtualizerfor large trees - Controlled expansion — Bind
v-model:expandedto open and close branches programmatically
Installation
Install from the Vuzeno registry with the shadcn-vue CLI:
bunx --bun shadcn-vue@latest add https://vuzeno.com/r/tree-view.json
Anatomy
<template>
<TreeView v-slot="{ flattenItems }" :items :get-key="(item) => item.id">
<TreeViewItem
v-for="item in flattenItems"
:key="item._id"
v-slot="slotProps"
v-bind="item.bind"
>
<TreeViewIndicator v-bind="slotProps" :has-children="item.hasChildren" />
{{ item.value.title }}
</TreeViewItem>
</TreeView>
</template>
- TreeView — Wraps
TreeRoot. Forwards all Reka Tree props and exposes theflattenItemsslot prop. - TreeViewItem — Wraps
TreeItem. Passv-bind="item.bind"fromflattenItems. ExposesisExpanded,isSelected,isIndeterminate,handleToggle, andhandleSelecton the default slot. - TreeViewIndicator — Optional expand control for branch nodes. Pass slot props from
TreeViewItemplushas-childrenfrom the flattened item. - TreeViewVirtualizer — Wraps
TreeVirtualizerfor virtualized lists. Place insideTreeViewinstead of iteratingflattenItemsmanually.
Examples
Variants
Set guideline on TreeView to draw connector lines between branches. Use "straight" for vertical guides or "rounded" for elbow connectors on child rows. Omit the prop (or use "none") for a plain list.
<script setup lang="ts">
import { TreeView, TreeViewIndicator, TreeViewItem } from "@/components/tree-view";
type TreeNode = {
id: string;
title: string;
children?: TreeNode[];
};
const items: TreeNode[] = [
{
id: "src",
title: "src",
children: [
{
id: "components",
title: "components",
children: [
{ id: "button", title: "Button.vue" },
{ id: "input", title: "Input.vue" },
],
},
{ id: "app", title: "App.vue" },
],
},
{ id: "package.json", title: "package.json" },
];
const defaultExpanded = ["src", "components"];
const itemClass = "flex min-h-8 items-center gap-2 rounded-sm px-2 text-xs outline-none hover:bg-accent/30 cursor-default select-none";
const indicatorClass = "inline-flex size-4 shrink-0 items-center justify-center text-muted-foreground";
</script>
<template>
<div class="grid w-full max-w-2xl gap-6 sm:grid-cols-2">
<div class="space-y-2">
<p class="text-xs font-medium text-muted-foreground">Straight</p>
<TreeView
v-slot="{ flattenItems }"
guideline="straight"
:items
:get-key="(item) => item.id"
:default-expanded="defaultExpanded"
class="rounded-lg border p-2"
>
<TreeViewItem
v-for="item in flattenItems"
:key="item._id"
v-slot="slotProps"
v-bind="item.bind"
:class="itemClass"
>
<TreeViewIndicator
v-bind="slotProps"
:has-children="item.hasChildren"
:class="indicatorClass"
/>
{{ item.value.title }}
</TreeViewItem>
</TreeView>
</div>
<div class="space-y-2">
<p class="text-xs font-medium text-muted-foreground">Rounded</p>
<TreeView
v-slot="{ flattenItems }"
guideline="rounded"
:items
:get-key="(item) => item.id"
:default-expanded="defaultExpanded"
class="rounded-lg border p-2"
>
<TreeViewItem
v-for="item in flattenItems"
:key="item._id"
v-slot="slotProps"
v-bind="item.bind"
:class="itemClass"
>
<TreeViewIndicator
v-bind="slotProps"
:has-children="item.hasChildren"
:class="indicatorClass"
/>
{{ item.value.title }}
</TreeViewItem>
</TreeView>
</div>
</div>
</template>
<template>
<TreeView
v-slot="{ flattenItems }"
guideline="rounded"
:items
:get-key="(item) => item.id"
>
<TreeViewItem
v-for="item in flattenItems"
:key="item._id"
v-slot="slotProps"
v-bind="item.bind"
>
<TreeViewIndicator v-bind="slotProps" :has-children="item.hasChildren" />
{{ item.value.title }}
</TreeViewItem>
</TreeView>
</template>
| Value | Description |
|---|---|
none | Default. No connector lines. |
straight | Vertical line through expanded branch children. |
rounded | Vertical line plus rounded elbows on child rows. |
Virtual list
For large trees, swap the flattenItems loop for TreeViewVirtualizer. Give the virtualizer a fixed height and pass text-content so type-ahead search can resolve item labels.
<script setup lang="ts">
import { TreeView, TreeViewIndicator, TreeViewItem, TreeViewVirtualizer } from "@/components/tree-view";
type TreeNode = {
id: string;
title: string;
children?: TreeNode[];
};
function buildFiles(prefix: string, count: number): TreeNode[] {
return Array.from({ length: count }, (_, index) => ({
id: `${prefix}-${index}`,
title: `${prefix}-${index + 1}.ts`,
}));
}
const items: TreeNode[] = [
{
id: "src",
title: "src",
children: buildFiles("src", 250),
},
{
id: "lib",
title: "lib",
children: buildFiles("lib", 150),
},
];
const defaultExpanded = ["src", "lib"];
const itemClass = "flex min-h-8 items-center gap-2 rounded-sm px-2 text-xs outline-none hover:bg-accent/30 cursor-default select-none";
const indicatorClass = "inline-flex size-4 shrink-0 items-center justify-center text-muted-foreground";
</script>
<template>
<TreeView
:items
:get-key="(item) => item.id"
:default-expanded="defaultExpanded"
class="w-full max-w-sm rounded-lg border p-2 h-96 overflow-y-auto"
>
<TreeViewVirtualizer
v-slot="{ item }"
:text-content="(node) => node.title"
:estimate-size="20"
class="overflow-y-auto"
>
<TreeViewItem
v-slot="slotProps"
v-bind="item.bind"
:class="itemClass"
>
<TreeViewIndicator
v-bind="slotProps"
:has-children="item.hasChildren"
:class="indicatorClass"
/>
{{ item.value.title }}
</TreeViewItem>
</TreeViewVirtualizer>
</TreeView>
</template>
<template>
<TreeView :items :get-key="(item) => item.id">
<TreeViewVirtualizer
v-slot="{ item }"
:text-content="(node) => node.title"
class="h-64"
>
<TreeViewItem v-slot="slotProps" v-bind="item.bind">
<TreeViewIndicator v-bind="slotProps" :has-children="item.hasChildren" />
{{ item.value.title }}
</TreeViewItem>
</TreeViewVirtualizer>
</TreeView>
</template>