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.
<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',
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" default-expanded />
</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" default-expanded />
</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 expanded = ref<string[]>(['guest', 'downloads', 'documents', 'archive'])
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 = []
dest = parent.children
if (!expanded.value.includes(info.to as string))
expanded.value = [...expanded.value, info.to as string]
}
}
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"
v-model:expanded="expanded"
: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',
},
],
},
])
const expanded = ref<string[]>(['james', 'wade'])
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"
v-model:expanded="expanded"
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' },
],
},
])
const expanded = ref(['src'])
</script>
<template>
<Tree :nodes="nodes" node-key="name" v-model:expanded="expanded" />
</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
Two inputs affect which rows are open — reach for one:
v-model:expanded— the array of open node keys, and the source of truth. Bind it to control or observe expansion; leave it unbound and the tree tracks the set internally. This is the one you want in most cases.defaultExpanded— a boolean shortcut for "start with everything open." It seedsexpandedonce on first load (and waits for asyncnodes), then steps aside — it does nothing once you provide your ownexpandedkeys.
Clicking a row, or pressing Enter/Space on it, toggles expansion.
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[]
}
/** 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'
/**
* Start with every node expanded. A one-shot convenience that seeds the open
* set on first load (async-safe — it waits for `nodes` to arrive). To open
* specific nodes or track expansion, use `v-model:expanded` instead; this is
* ignored once you provide your own keys.
* @default false
*/
defaultExpanded?: boolean
/**
* 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.
Start with every node expanded. A one-shot convenience that seeds the open set on first load (async-safe — it waits for `nodes` to arrive). To open specific nodes or track expansion, use `v-model:expanded` instead; this is ignored once you provide your own keys.
Disable all interaction — expand/collapse and drag.
The keys of the currently expanded nodes — the live source of truth for which rows are open. Controlled or uncontrolled. Use `defaultExpanded` only for the simple "start fully expanded" case.
| Slot | Payload |
|---|---|
item | TreeNodeSlotProps |
item-prefix | Omit<TreeNodeSlotProps, "toggle"> |
item-label | Omit<TreeNodeSlotProps, "toggle"> |
item-suffix | Omit<TreeNodeSlotProps, "toggle"> |
empty | — |
| Event | Payload |
|---|---|
update:expanded | [value: TreeKey[]] Fired when the expanded changes. |
drag-start | [node: TreeNode] |
drag-end | [info: DropInfo | null] |
Fired when the expanded changes.