Tree
Displays hierarchical data as a collapsible tree. Renders a forest of roots with keyboard navigation and optional drag-and-drop reparenting/reordering. Connector guides visually link parents to their children.
Default
The simplest tree — pass nodes and tell it which field is the key. Nodes are expanded by default; here Documents carries expanded: false to start collapsed.
<script setup lang="ts">
import { ref } from 'vue'
import { Tree } from 'frappe-ui'
import type { TreeNode } from '../types'
const nodes = ref<TreeNode[]>([
{
name: 'guest',
label: 'Guest',
children: [
{
name: 'downloads',
label: 'Downloads',
children: [
{
name: 'download.zip',
label: 'download.zip',
children: [{ name: 'image.png', label: 'image.png' }],
},
],
},
{
name: 'documents',
label: 'Documents',
expanded: false,
children: [
{ name: 'somefile.txt', label: 'somefile.txt' },
{ name: 'somefile.pdf', label: 'somefile.pdf' },
],
},
],
},
])
</script>
<template>
<div class="w-80">
<Tree :nodes="nodes" node-key="name" />
</div>
</template>Expand / collapse all
Bind v-model:expanded to a boolean for a master switch — toggling it opens or closes every node. It's two-way, so it also reflects whether all nodes are currently open as the user toggles rows individually.
<script setup lang="ts">
import { ref } from 'vue'
import { Button, Tree } from 'frappe-ui'
import type { TreeNode } from '../types'
const nodes = ref<TreeNode[]>([
{
name: 'guest',
label: 'Guest',
children: [
{
name: 'downloads',
label: 'Downloads',
children: [
{
name: 'download.zip',
label: 'download.zip',
children: [{ name: 'image.png', label: 'image.png' }],
},
],
},
{
name: 'documents',
label: 'Documents',
children: [
{ name: 'somefile.txt', label: 'somefile.txt' },
{ name: 'somefile.pdf', label: 'somefile.pdf' },
],
},
],
},
])
// `expanded` mirrors whether every node is open (nodes start expanded), so the
// label stays in sync even when rows are toggled individually.
const expanded = ref(true)
</script>
<template>
<div class="flex flex-col gap-3">
<Button class="self-start" @click="expanded = !expanded">
{{ expanded ? 'Collapse all' : 'Expand all' }}
</Button>
<div class="w-80">
<Tree :nodes="nodes" node-key="name" v-model:expanded="expanded" />
</div>
</div>
</template>Indentation guides
guides controls how nesting is drawn: connectors (elbow lines), lines (plain vertical rules), or none. The indentation itself stays either way — guides only changes the lines.
<script setup lang="ts">
import { ref } from 'vue'
import { TabButtons, Tree } from 'frappe-ui'
import type { TreeNode } from '../types'
const nodes = ref<TreeNode[]>([
{
name: 'guest',
label: 'Guest',
children: [
{
name: 'downloads',
label: 'Downloads',
children: [
{
name: 'download.zip',
label: 'download.zip',
children: [{ name: 'image.png', label: 'image.png' }],
},
],
},
{
name: 'documents',
label: 'Documents',
children: [
{ name: 'somefile.txt', label: 'somefile.txt' },
{ name: 'somefile.pdf', label: 'somefile.pdf' },
],
},
],
},
])
const guides = ref<'connectors' | 'lines' | 'none'>('connectors')
const guideOptions = [
{ label: 'Connectors', value: 'connectors' },
{ label: 'Lines', value: 'lines' },
{ label: 'None', value: 'none' },
]
</script>
<template>
<div class="flex flex-col gap-4">
<TabButtons v-model="guides" :options="guideOptions" />
<div class="w-80">
<Tree :nodes="nodes" node-key="name" :guides="guides" />
</div>
</div>
</template>Drag and drop
Set draggable to let nodes be dragged onto one another to reparent, or between siblings to reorder. A move predicate gates where drops are allowed (here, only into folders), and @drag-end hands you the committed move to persist. This example is fully working — drag a file into a folder and it stays there. Flip Disable interaction to see the disabled state freeze drag and expand/collapse.
<script setup lang="ts">
import { ref } from 'vue'
import { Switch, Tree } from 'frappe-ui'
import type { DropInfo, MoveContext, TreeNode } from '../types'
const nodes = ref<TreeNode[]>([
{
name: 'guest',
label: 'Guest',
children: [
{
name: 'downloads',
label: 'Downloads',
children: [
{
name: 'archive',
label: 'archive',
children: [{ name: 'image.png', label: 'image.png' }],
},
],
},
{
name: 'documents',
label: 'Documents',
children: [
{ name: 'resume.pdf', label: 'resume.pdf' },
{ name: 'notes.txt', label: 'notes.txt' },
],
},
],
},
])
const disabled = ref(false)
// Folders (nodes with a `children` array) can receive drops; files cannot.
function move({ target, position }: MoveContext) {
if (position === 'inside') return Array.isArray(target.children)
return true
}
// Locate a node by key, returning the array that holds it and its index.
function locate(
list: TreeNode[],
key: string,
): { list: TreeNode[]; index: number } | null {
for (let i = 0; i < list.length; i++) {
if (list[i].name === key) return { list, index: i }
const children = list[i].children
if (children) {
const found = locate(children, key)
if (found) return found
}
}
return null
}
// Persist a committed move by splicing the node into its new position.
function onDragEnd(info: DropInfo | null) {
if (!info) return
const roots = nodes.value
const src = locate(roots, info.node.name as string)
if (!src) return
const [moved] = src.list.splice(src.index, 1)
let dest = roots
if (info.to !== null) {
const hit = locate(roots, info.to as string)
const parent = hit?.list[hit.index]
if (parent) {
if (!parent.children) parent.children = []
parent.expanded = true
dest = parent.children
}
}
dest.splice(info.newIndex, 0, moved)
}
</script>
<template>
<div class="flex flex-col gap-3">
<Switch v-model="disabled" label="Disable interaction" />
<div class="w-80">
<Tree
:nodes="nodes"
node-key="name"
:disabled="disabled"
draggable
:move="move"
@drag-end="onDragEnd"
/>
</div>
</div>
</template>List view with avatars and row actions
Use the #item-prefix, #item-label, and #item-suffix slots to turn the tree into a rich list — an avatar on the left, a two-line label, and a row action (an add button) on the right. guides="none" drops the connector lines for a plain list look.
<script setup lang="ts">
import { ref } from 'vue'
import { Avatar, Button, Tree } from 'frappe-ui'
import type { TreeNode } from '../types'
const nodes = ref<TreeNode[]>([
{
id: 'james',
label: 'James Cooper',
role: 'Head of Sales',
image: 'https://i.pravatar.cc/80?img=11',
children: [
{
id: 'wade',
label: 'Wade Warren',
role: 'Account Executive',
image: 'https://i.pravatar.cc/80?img=12',
children: [
{
id: 'ethan',
label: 'Ethan Howard',
role: 'Sales Rep',
image: 'https://i.pravatar.cc/80?img=13',
},
],
},
{
id: 'cody',
label: 'Cody Fisher',
role: 'Account Executive',
image: 'https://i.pravatar.cc/80?img=15',
},
],
},
])
function addReport(node: TreeNode) {
console.log('add report under', node.id)
}
</script>
<template>
<div class="w-96" style="--tree-row-height: 48px">
<Tree :nodes="nodes" node-key="id" guides="none">
<!-- Avatar on the left -->
<template #item-prefix="{ node }">
<Avatar
:image="node.image as string"
:label="node.label as string"
size="lg"
/>
</template>
<template #item-label="{ node }">
<div class="flex min-w-0 flex-col">
<span class="truncate text-base text-ink-gray-8">{{
node.label
}}</span>
<span class="truncate text-sm text-ink-gray-5">{{ node.role }}</span>
</div>
</template>
<!-- Add action -->
<template #item-suffix="{ node }">
<Button
variant="ghost"
icon="plus"
:aria-label="`Add report under ${node.label}`"
@click.stop="addReport(node)"
/>
</template>
</Tree>
</div>
</template>Usage
<script setup>
import { ref } from 'vue'
import { Tree } from 'frappe-ui'
const nodes = ref([
{
name: 'src',
label: 'src',
children: [
{ name: 'index.ts', label: 'index.ts' },
{ name: 'app.vue', label: 'app.vue' },
],
},
])
</script>
<template>
<Tree :nodes="nodes" node-key="name" />
</template>Node shape
Each node is a plain object with a label (display text) and optional children. Its unique id lives under the field named by nodeKey (e.g. name). A node is a leaf when children is missing or empty. Extra fields are passed through to the slots, so you can render avatars, roles, badges, etc.
To display a field other than label, use the #item-label slot rather than remapping.
Expansion
Each node owns its own state via an expanded field — the source of truth the tree reads and writes as rows toggle, so expansion travels with your data. Nodes are expanded by default; set expanded: false to start one collapsed.
v-model:expanded is a separate, optional boolean switch for the whole tree: toggle it to open or close everything at once (see Expand / collapse all). It's two-way, reflecting whether all nodes are currently open.
Clicking a row, or pressing Enter/Space on it, toggles that node.
Keyboard
Following the WAI-ARIA Tree View pattern: ↑/↓ move between visible rows, → expands or steps into children, ← collapses or steps to the parent, Home/End jump to the first/last row, Enter/Space toggle expansion, and typing letters jumps to the next matching label.
Drag and drop
Set draggable to enable dragging. The tree resolves the drop position from the cursor (before / inside / after) and shows a live indicator.
move(ctx)— an optional predicate called as you hover. Returnfalseto reject a target (it shows the no-drop cursor and hides the indicator). Built-in guards already reject drop-on-self and drop-into-own-descendant, somoveonly carries your domain rules.ctxis{ node, target, position }.@drag-start(node)— fires when a drag is picked up.@drag-end(info)— fires when the drag ends.infois aDropInfoon a committed move, ornullif the drag was cancelled. Apply it to your data and updatenodes.
<Tree
:nodes="nodes"
node-key="name"
draggable
:move="({ node, target, position }) => Array.isArray(target.children)"
@drag-end="onDragEnd"
/>DropInfo is { node, from, to, position, oldIndex, newIndex } — from/to are the old/new parent keys (null at root level) and newIndex is the node's final index within its new parent, already accounting for its removal.
Customizing rows
Use the #item slot to fully replace a row (you receive toggle plus state), or the lighter #item-label, #item-prefix, and #item-suffix slots to keep the default layout. Style via the data-slot, data-state, data-drop and data-level attributes rather than class props.
Row height and indentation are CSS variables — override them in CSS rather than through props:
.my-tree {
--tree-row-height: 40px;
--tree-indent: 24px;
}API Reference
Show types
import type { ComputedRef, InjectionKey, Ref } from 'vue'
/** A node key — the value read from `nodeKey` on each node. */
export type TreeKey = string | number
/**
* A tree node. Carries a display `label`, nested `children`, and a unique id
* under the field named by the `nodeKey` prop. Any extra fields are preserved
* and passed through to slots.
*/
export type TreeNode = {
[key: string]: unknown
label?: string
children?: TreeNode[]
/**
* Whether this node is expanded — the per-node source of truth. Expanded by
* default; set `false` to start it collapsed.
*/
expanded?: boolean
}
/** Where a dragged node lands relative to the hovered target. */
export type DropPosition = 'inside' | 'before' | 'after'
/** Context passed to the `move` predicate while dragging. */
export interface MoveContext {
/** The node being dragged. */
node: TreeNode
/** The node currently hovered as the drop target. */
target: TreeNode
/** Resolved drop position relative to `target`. */
position: DropPosition
}
/**
* The committed move handed to `@drag-end`. `null` is emitted instead when a
* drag is cancelled (Escape, or released without a valid landing).
*/
export interface DropInfo {
/** The moved node. */
node: TreeNode
/** Key of the previous parent, or `null` if it was a root. */
from: TreeKey | null
/** Key of the new parent, or `null` when moved to root level. */
to: TreeKey | null
/** Drop position relative to the target node. */
position: DropPosition
/** Index the node occupied in its previous parent. */
oldIndex: number
/** Final index of the node within its new parent's children. */
newIndex: number
}
export interface TreeProps {
/**
* Forest roots to render. Each node may contain nested children to form the
* tree structure.
*/
nodes: TreeNode[]
/**
* Name of the field that uniquely identifies each node.
* @default 'key'
*/
nodeKey?: string
/**
* Enable drag-and-drop. Nodes can be dragged onto one another to reparent, or
* between siblings to reorder.
* @default false
*/
draggable?: boolean
/**
* Gate a drop while dragging. Receives the live drag context and returns
* whether the drop is allowed — a rejected target shows the no-drop cursor and
* hides the drop indicator. Built-in guards (drop-on-self, drop-into-own-
* descendant) run first, so this only carries your domain rules.
*/
move?: (ctx: MoveContext) => boolean
/**
* Visual style of the indentation guides.
* @default 'connectors'
*/
guides?: 'connectors' | 'lines' | 'none'
/**
* Disable all interaction — expand/collapse and drag.
* @default false
*/
disabled?: boolean
}
/** State + callbacks shared from `Tree` down to every recursive `TreeItem`. */
export interface TreeContext {
nodeKey: Ref<string>
guides: Ref<'connectors' | 'lines' | 'none'>
draggable: Ref<boolean>
disabled: Ref<boolean>
focusedKey: Ref<TreeKey | null>
keyOf: (node: TreeNode) => TreeKey
labelOf: (node: TreeNode) => string
childrenOf: (node: TreeNode) => TreeNode[]
hasChildren: (node: TreeNode) => boolean
isExpanded: (node: TreeNode) => boolean
toggle: (node: TreeNode) => void
focus: (key: TreeKey) => void
registerItem: (key: TreeKey, el: HTMLElement) => void
unregisterItem: (key: TreeKey) => void
// Drag-and-drop
dragSourceKey: ComputedRef<TreeKey | null>
dropTargetKey: ComputedRef<TreeKey | null>
dropPosition: ComputedRef<DropPosition | null>
onDragStart: (e: DragEvent, node: TreeNode, parent: TreeNode | null) => void
onDragOver: (e: DragEvent, node: TreeNode) => void
onDragLeave: (node: TreeNode) => void
onDrop: (node: TreeNode, parent: TreeNode | null) => void
onDragEnd: () => void
}
export const TreeContextKey: InjectionKey<TreeContext> = Symbol('TreeContext')
export interface TreeNodeSlotProps {
node: TreeNode
level: number
expanded: boolean
hasChildren: boolean
focused: boolean
disabled: boolean
toggle: () => void
}Forest roots to render. Each node may contain nested children to form the tree structure.
Name of the field that uniquely identifies each node.
Enable drag-and-drop. Nodes can be dragged onto one another to reparent, or between siblings to reorder.
Gate a drop while dragging. Receives the live drag context and returns whether the drop is allowed — a rejected target shows the no-drop cursor and hides the drop indicator. Built-in guards (drop-on-self, drop-into-own- descendant) run first, so this only carries your domain rules.
Visual style of the indentation guides.
Disable all interaction — expand/collapse and drag.
Expand/collapse-all switch. Toggling it writes that value into every node's `expanded` field. Two-way: it also reflects whether all collapsible nodes are currently open, so a bound button stays in sync. Per-node state lives on the nodes themselves (`node.expanded`).
| Slot | Payload |
|---|---|
item | TreeNodeSlotProps Fully replaces a row's default rendering — receives `toggle` plus node state. |
item-prefix | Omit<TreeNodeSlotProps, "toggle"> Leading content before the label, inside the default row layout. |
item-label | Omit<TreeNodeSlotProps, "toggle"> Overrides the row's label text, inside the default row layout. |
item-suffix | Omit<TreeNodeSlotProps, "toggle"> Trailing content after the label, inside the default row layout. |
empty | — Rendered instead of the tree when `nodes` is empty. |
Fully replaces a row's default rendering — receives `toggle` plus node state.
Leading content before the label, inside the default row layout.
Overrides the row's label text, inside the default row layout.
Trailing content after the label, inside the default row layout.
Rendered instead of the tree when `nodes` is empty.
| Event | Payload |
|---|---|
update:expanded | [value: boolean] Fired when the expanded changes. |
drag-start | [node: TreeNode] Fired when a drag is picked up. |
drag-end | [info: DropInfo | null] Fired when a drag ends: the committed move, or `null` if it was cancelled. |
Fired when the expanded changes.
Fired when a drag is picked up.
Fired when a drag ends: the committed move, or `null` if it was cancelled.