Component

Tree view

<DomTreeView>

Keyboard navigable trees for layers panels, file browsers, document outlines, and async server-backed hierarchies.

Playground

Try every prop live

Tree view playground

Edit the data, select nodes, expand branches, and reorder items with drag and drop.

Use Alt plus Up or Down arrow to reorder the focused tree item.

Frame
Content
Footer
Playground.vuevue
<script setup>
import { reactive } from 'vue';
import { DomTreeView } from '@getdom/studio/vue';

const data = reactive({
	  "modelValue": "content",
	  "items": [
	    {
	      "id": "frame",
	      "label": "Frame",
	      "icon": "M4 6h6l2 2h8v10H4V6Z",
	      "open": true,
	      "children": [
	        {
	          "id": "header",
	          "label": "Header",
	          "icon": "M4 6h6l2 2h8v10H4V6Z",
	          "children": [
	            {
	              "id": "logo",
	              "label": "Logo",
	              "icon": "M6 4h8l4 4v12H6V4Zm8 0v5h5",
	              "value": "",
	              "name": "",
	              "type": "",
	              "rightIcon": "",
	              "rightIconAction": "",
	              "rightIconLabel": "",
	              "open": false,
	              "lazy": false,
	              "loading": false,
	              "disabled": false,
	              "draggable": false,
	              "acceptsChildren": false,
	              "slot": "",
	              "actions": [],
	              "children": []
	            }
	          ],
	          "value": "",
	          "name": "",
	          "type": "",
	          "rightIcon": "",
	          "rightIconAction": "",
	          "rightIconLabel": "",
	          "open": false,
	          "lazy": false,
	          "loading": false,
	          "disabled": false,
	          "draggable": false,
	          "acceptsChildren": false,
	          "slot": "",
	          "actions": []
	        },
	        {
	          "id": "content",
	          "label": "Content",
	          "icon": "M6 4h8l4 4v12H6V4Zm8 0v5h5",
	          "value": "",
	          "name": "",
	          "type": "",
	          "rightIcon": "",
	          "rightIconAction": "",
	          "rightIconLabel": "",
	          "open": false,
	          "lazy": false,
	          "loading": false,
	          "disabled": false,
	          "draggable": false,
	          "acceptsChildren": false,
	          "slot": "",
	          "actions": [],
	          "children": []
	        }
	      ],
	      "value": "",
	      "name": "",
	      "type": "",
	      "rightIcon": "",
	      "rightIconAction": "",
	      "rightIconLabel": "",
	      "lazy": false,
	      "loading": false,
	      "disabled": false,
	      "draggable": false,
	      "acceptsChildren": false,
	      "slot": "",
	      "actions": []
	    },
	    {
	      "id": "footer",
	      "label": "Footer",
	      "icon": "M6 4h8l4 4v12H6V4Zm8 0v5h5",
	      "value": "",
	      "name": "",
	      "type": "",
	      "rightIcon": "",
	      "rightIconAction": "",
	      "rightIconLabel": "",
	      "open": false,
	      "lazy": false,
	      "loading": false,
	      "disabled": false,
	      "draggable": false,
	      "acceptsChildren": false,
	      "slot": "",
	      "actions": [],
	      "children": []
	    }
	  ],
	  "openValues": [],
	  "hoveredValue": "",
	  "draggable": true,
	  "externalDragValue": "",
	  "externalDropTargetValue": "",
	  "externalDropPosition": "",
	  "externalDropTypes": [],
	  "externalDropEffect": "copy",
	  "dragExpandDelay": 800,
	  "canDropItem": null,
	  "canDropExternal": null,
	  "scrollIntoView": true,
	  "density": "compact",
	  "toggleTransition": true,
	  "variant": "default",
	  "chrome": true,
	  "label": "Tree view"
	});
</script>

<template>
	<DomTreeView
		v-bind="data"
		@update:modelValue="data.modelValue = $event"
	/>
</template>

Demo

Figma-style layers

Use v-model to programmatically highlight a layer from the stage, custom slots for metadata, and drag/drop to reorder the tree.

Use Alt plus Up or Down arrow to reorder the focused tree item.

Landing page
Hero
Headline
Feature card

Stage

Selected: hero-title

Hero group

Layers.vuevue
<script setup>
import { ref } from 'vue';
import { DomTreeView } from '../../../lib/vue';

const frameIcon = 'M4 5h16v14H4V5Zm4 0v14M4 9h16';
const groupIcon = 'M4 6h6l2 2h8v10H4V6Z';
const rectIcon = 'M5 6h14v12H5V6Z';
const textIcon = 'M5 7h14M12 7v10M8 17h8';
const lockIcon = 'M8 11V8a4 4 0 0 1 8 0v3M7 11h10v8H7v-8Z';
const eyeIcon = 'M2 12s4-6 10-6 10 6 10 6-4 6-10 6S2 12 2 12Zm10 3a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z';
const eyeOffIcon = 'M3 3l18 18M10.6 10.6A2 2 0 0 0 13.4 13.4M9.9 5.2A9.8 9.8 0 0 1 12 5c6 0 10 7 10 7a16.2 16.2 0 0 1-3.1 3.9M6.5 6.5C3.8 8.2 2 12 2 12s4 7 10 7c1.4 0 2.7-.4 3.8-1';

const selected = ref('hero-title');
const tree = ref([
	{
		id: 'page',
		label: 'Landing page',
		icon: frameIcon,
		open: true,
		children: [
			{
				id: 'hero',
				label: 'Hero',
				icon: groupIcon,
				open: true,
				children: [
					{
						id: 'hero-title',
						label: 'Headline',
						icon: textIcon,
						visible: true,
						actions: [{ value: 'visibility', label: 'Toggle visibility', icon: eyeIcon }],
					},
					{ id: 'hero-card', label: 'Feature card', icon: rectIcon },
				],
			},
			{
				id: 'locked-nav',
				label: 'Locked nav',
				icon: groupIcon,
				actions: [{ value: 'locked', label: 'Locked subtree', icon: lockIcon, disabled: true }],
				acceptsChildren: false,
				children: [
					{ id: 'logo', label: 'Logo', icon: rectIcon },
				],
			},
		],
	},
]);

function selectFromStage(id) {
	selected.value = id;
}

function updateNode(nodes, id, callback) {
	for (const node of nodes) {
		if (node.id === id) {
			callback(node);
			return true;
		}
		if (Array.isArray(node.children) && updateNode(node.children, id, callback)) return true;
	}
	return false;
}

function onAction({ action, item }) {
	if (action.value !== 'visibility') return;
	updateNode(tree.value, item.id, (node) => {
		node.visible = !node.visible;
		node.actions = [{ value: 'visibility', label: 'Toggle visibility', icon: node.visible ? eyeIcon : eyeOffIcon }];
	});
}
</script>

<template>
	<div class="grid w-full gap-4 lg:grid-cols-[18rem_minmax(0,1fr)]">
		<DomTreeView
			v-model="selected"
			v-model:items="tree"
			@reorder="tree = $event.items"
			@action="onAction"
		>
			<template #item="{ item }">
				<span class="flex min-w-0 items-center gap-2">
					<span class="truncate">{{ item.label }}</span>
					<span v-if="item.acceptsChildren === false" class="rounded-full bg-secondary px-1.5 py-0.5 text-[10px] text-muted-fg group-aria-selected:bg-primary-fg/15 group-aria-selected:text-primary-fg/80">
						locked
					</span>
				</span>
			</template>
		</DomTreeView>

		<div class="rounded-2xl border border-border bg-secondary/40 p-4">
			<div class="mb-3 flex items-center justify-between">
				<p class="text-sm font-medium text-fg">Stage</p>
				<p class="text-xs text-muted-fg">Selected: <code>{{ selected }}</code></p>
			</div>
			<div class="space-y-3 rounded-xl border border-border bg-background p-4">
				<div class="w-full rounded-xl border border-border p-4 text-left transition hover:border-ring" :class="selected === 'hero' && 'ring-2 ring-ring/40'" @click="selectFromStage('hero')">
					<p class="text-xs font-medium uppercase tracking-wide text-muted-fg">Hero group</p>
					<button type="button" class="mt-3 rounded-lg bg-primary px-3 py-2 text-sm font-medium text-primary-fg" :class="selected === 'hero-title' && 'ring-2 ring-ring/50'" @click.stop="selectFromStage('hero-title')">Headline layer</button>
				</div>
				<button type="button" class="rounded-xl border border-border skin-raised p-4 text-sm transition hover:border-ring" :class="selected === 'hero-card' && 'ring-2 ring-ring/40'" @click="selectFromStage('hero-card')">
					Feature card layer
				</button>
			</div>
		</div>
	</div>
</template>

Demo

External drag/drop

Accept drops from a palette by declaring DataTransfer types, then apply the insertion in your store.

Palette

Use Alt plus Up or Down arrow to reorder the focused tree item.

Landing page
Hero section
Headline
Feature card
ExternalDrops.vuevue
<script setup>
import { ref } from 'vue';
import { DomTreeView } from '../../../lib/vue';

const paletteDropType = 'application/x-getdom-tree-palette';
const folderIcon = 'M4 6h6l2 2h8v10H4V6Z';
const rectIcon = 'M5 6h14v12H5V6Z';
const textIcon = 'M5 7h14M12 7v10M8 17h8';
const buttonIcon = 'M8 8h8a4 4 0 0 1 0 8H8a4 4 0 0 1 0-8Z';

let nextId = 1;
const selected = ref('hero');
const openValues = ref(['page', 'hero']);
const snippets = [
	{ id: 'section', label: 'Section', icon: folderIcon, acceptsChildren: true },
	{ id: 'headline', label: 'Headline', icon: textIcon, acceptsChildren: false },
	{ id: 'button', label: 'Button', icon: buttonIcon, acceptsChildren: false },
];
const tree = ref([
	{
		id: 'page',
		label: 'Landing page',
		icon: folderIcon,
		acceptsChildren: true,
		children: [
			{
				id: 'hero',
				label: 'Hero section',
				icon: folderIcon,
				acceptsChildren: true,
				children: [
					{ id: 'hero-title', label: 'Headline', icon: textIcon, acceptsChildren: false },
					{ id: 'hero-card', label: 'Feature card', icon: rectIcon, acceptsChildren: false },
				],
			},
		],
	},
]);

function onPaletteDragStart(snippet, event) {
	event.dataTransfer.effectAllowed = 'copy';
	event.dataTransfer.setData(paletteDropType, snippet.id);
	event.dataTransfer.setData('text/plain', snippet.label);
}

function canDropPalette({ item, position }) {
	if (position === 'inside') return item.acceptsChildren !== false;
	return item.id !== 'page';
}

function onExternalDrop(drop) {
	const snippet = snippets.find((item) => item.id === drop.getData(paletteDropType));
	if (!snippet) return;
	const node = {
		id: `${snippet.id}-${nextId}`,
		label: snippet.label,
		icon: snippet.icon,
		acceptsChildren: snippet.acceptsChildren,
		children: snippet.acceptsChildren ? [] : undefined,
	};
	nextId += 1;
	const next = insertTreeItem(tree.value, drop.value, drop.position, node);
	if (!next) return;
	tree.value = next;
	selected.value = node.id;
	if (drop.position === 'inside') {
		openValues.value = [...new Set([...openValues.value, String(drop.value)])];
	}
}

function insertTreeItem(items, targetValue, position, item) {
	const next = cloneItems(items);
	return insertInto(next, targetValue, position, item) ? next : null;
}

function insertInto(items, targetValue, position, item) {
	for (let index = 0; index < items.length; index += 1) {
		if (String(items[index].id) === String(targetValue)) {
			if (position === 'inside') {
				if (!Array.isArray(items[index].children)) items[index].children = [];
				items[index].children.push(item);
			} else {
				items.splice(position === 'before' ? index : index + 1, 0, item);
			}
			return true;
		}
		if (Array.isArray(items[index].children) && insertInto(items[index].children, targetValue, position, item)) return true;
	}
	return false;
}

function cloneItems(items) {
	return items.map((item) => ({
		...item,
		children: Array.isArray(item.children) ? cloneItems(item.children) : item.children,
	}));
}
</script>

<template>
	<div class="grid w-full gap-4 lg:grid-cols-[14rem_minmax(0,1fr)]">
		<div class="rounded-xl border border-border bg-secondary/40 p-3">
			<p class="mb-3 text-xs font-semibold uppercase tracking-wide text-muted-fg">Palette</p>
			<div class="grid gap-2">
				<button
					v-for="snippet in snippets"
					:key="snippet.id"
					type="button"
					draggable="true"
					class="flex h-9 items-center gap-2 rounded-lg border border-border bg-background px-3 text-left text-sm text-fg transition hover:border-ring hover:skin-raised"
					@dragstart="onPaletteDragStart(snippet, $event)"
				>
					<svg class="size-4 text-muted-fg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round" aria-hidden="true">
						<path :d="snippet.icon" />
					</svg>
					<span>{{ snippet.label }}</span>
				</button>
			</div>
		</div>

		<DomTreeView
			v-model="selected"
			v-model:items="tree"
			v-model:open-values="openValues"
			:external-drop-types="[paletteDropType]"
			:can-drop-external="canDropPalette"
			label="External drop layers"
			@external-drop="onExternalDrop"
		/>
	</div>
</template>

Demo

File browser

A file tree can use DomContextMenu for folder, file, and empty-space actions while the tree only emits the right-click context.

Use Alt plus Up or Down arrow to reorder the focused tree item.

src
pages
DomStudioLayout.vue
routes.js
style.css

Selected path: src-pages-docs

Last context action: none

FileBrowser.vuevue
<script setup>
import { computed, ref } from 'vue';
import { DomContextMenu, DomTreeView } from '../../../lib/vue';

const folderIcon = 'M4 6h6l2 2h8v10H4V6Z';
const vueIcon = 'M4 5h16l-8 14L4 5Z';
const jsIcon = 'M6 4h12v16H6V4Zm4 11c0 1.5 1 2 2 2s2-.5 2-2V9';
const cssIcon = 'M6 4h12l-1 14-5 2-5-2L6 4Zm3 4h6M9 12h6';
const textIcon = 'M6 4h8l4 4v12H6V4Zm8 0v5h5M9 13h6M9 16h4';

const selected = ref('src-pages-docs');
const contextMenu = ref(null);
const context = ref({ kind: 'empty', item: null, value: null, node: null });
const lastAction = ref('none');
const newFolderCount = ref(1);
const newFileCount = ref(1);
const files = ref([
	{
		id: 'src',
		label: 'src',
		type: 'folder',
		icon: folderIcon,
		open: true,
		children: [
			{
				id: 'src-pages',
				label: 'pages',
				type: 'folder',
				icon: folderIcon,
				open: true,
				children: [
					{ id: 'src-pages-docs', label: 'DomStudioLayout.vue', type: 'file', icon: vueIcon },
					{ id: 'src-pages-routes', label: 'routes.js', type: 'file', icon: jsIcon },
				],
			},
			{ id: 'src-style', label: 'style.css', type: 'file', icon: cssIcon },
		],
	},
	{
		id: 'public',
		label: 'public',
		type: 'folder',
		icon: folderIcon,
		children: [
			{ id: 'public-llms', label: 'llms.txt', type: 'file', icon: textIcon },
		],
	},
]);

const contextItems = computed(() => {
	if (context.value.kind === 'empty') {
		return [
			{ label: 'New folder', value: 'new-folder' },
			{ label: 'New file', value: 'new-file' },
			{ separator: true },
			{ label: 'Paste', value: 'paste', disabled: true },
		];
	}

	if (context.value.item?.type === 'folder') {
		return [
			{ label: 'Open', value: 'open' },
			{ label: 'New folder', value: 'new-folder' },
			{ label: 'New file', value: 'new-file' },
			{ separator: true },
			{ label: 'Rename', value: 'rename' },
			{ label: 'Delete', value: 'delete', tone: 'danger' },
		];
	}

	return [
		{ label: 'Open', value: 'open' },
		{ label: 'Open with', value: 'open-with', children: [
			{ label: 'Code editor', value: 'open-code' },
			{ label: 'Preview', value: 'open-preview' },
		] },
		{ separator: true },
		{ label: 'Rename', value: 'rename' },
		{ label: 'Duplicate', value: 'duplicate' },
		{ label: 'Delete', value: 'delete', tone: 'danger' },
	];
});

function openContextMenu(payload) {
	context.value = payload;
	if (payload.kind === 'item') selected.value = payload.value;
	contextMenu.value?.open(payload.event);
}

function onMenuSelect({ value }) {
	lastAction.value = `${value} on ${contextLabel()}`;
	if (value === 'new-folder') addItemToContext({
		id: `folder-${newFolderCount.value}`,
		label: `New folder ${newFolderCount.value++}`,
		type: 'folder',
		icon: folderIcon,
		children: [],
	});
	if (value === 'new-file') addItemToContext({
		id: `file-${newFileCount.value}`,
		label: `new-file-${newFileCount.value++}.txt`,
		type: 'file',
		icon: textIcon,
	});
}

function contextLabel() {
	return context.value.item?.label || 'project root';
}

function addItemToContext(item) {
	if (context.value.kind === 'empty') {
		files.value = [...files.value, item];
		selected.value = item.id;
		return;
	}

	const target = findItem(files.value, context.value.value);
	if (!target || target.type !== 'folder') return;
	target.open = true;
	target.children = [...(target.children || []), item];
	selected.value = item.id;
}

function findItem(items, value) {
	for (const item of items) {
		if (String(item.id) === String(value)) return item;
		const match = findItem(item.children || [], value);
		if (match) return match;
	}
	return null;
}
</script>

<template>
	<div class="w-full max-w-md">
		<DomContextMenu
			ref="contextMenu"
			:items="contextItems"
			:open-on-context-menu="false"
			@select="onMenuSelect"
		>
			<DomTreeView
				v-model="selected"
				v-model:items="files"
				class="min-h-72"
				density="comfortable"
				@context-menu="openContextMenu"
			/>
		</DomContextMenu>
		<p class="mt-3 text-xs text-muted-fg">Selected path: <code class="text-fg">{{ selected }}</code></p>
		<p class="mt-1 text-xs text-muted-fg">Last context action: <code class="text-fg">{{ lastAction }}</code></p>
	</div>
</template>

Demo

Finder-style sidebar

Use the finder variant with toggleTransition for a softer sidebar tree that feels closer to an Apple Finder menu.

Favourites
AirDrop
Applications
Downloads
Tags
Work
Archive
FinderMenu.vuevue
<script setup>
import { ref } from 'vue';
import { DomTreeView } from '../../../lib/vue';

const folderIcon = 'M4 6h6l2 2h8v10H4V6Z';
const tagIcon = 'M4 5h7l9 9-6 6-9-9V5Zm4 4h.01';
const diskIcon = 'M5 5h14v14H5V5Zm3 10h8M8 8h8';

const selected = ref('downloads');
const places = ref([
	{
		id: 'favourites',
		label: 'Favourites',
		icon: folderIcon,
		open: true,
		children: [
			{ id: 'airdrop', label: 'AirDrop', icon: diskIcon },
			{ id: 'applications', label: 'Applications', icon: folderIcon },
			{ id: 'downloads', label: 'Downloads', icon: folderIcon },
		],
	},
	{
		id: 'tags',
		label: 'Tags',
		icon: tagIcon,
		open: true,
		children: [
			{ id: 'work', label: 'Work', icon: tagIcon },
			{ id: 'archive', label: 'Archive', icon: tagIcon },
		],
	},
]);
</script>

<template>
	<div class="w-full max-w-xs rounded-3xl border border-border bg-background p-3 shadow-xl shadow-black/5">
		<DomTreeView
			v-model="selected"
			:items="places"
			variant="finder"
			:draggable="false"
			toggle-transition
			class="border-0 bg-transparent shadow-none"
		>
			<template #item="{ item, depth }">
				<span
					class="truncate"
					:class="depth === 1 && 'text-[11px] font-semibold uppercase tracking-wide text-muted-fg group-aria-selected:text-accent-fg'"
				>{{ item.label }}</span>
			</template>
		</DomTreeView>
	</div>
</template>

Demo

Async loading

Lazy nodes emit load-children on expand. Show loading on the node, then replace its children from the server response.

Use Alt plus Up or Down arrow to reorder the focused tree item.

Workspace

Open a remote folder to simulate a server lookup.

AsyncLoading.vuevue
<script setup>
import { ref } from 'vue';
import { DomTreeView } from '../../../lib/vue';

const folderIcon = 'M4 6h6l2 2h8v10H4V6Z';
const fileIcon = 'M6 4h8l4 4v12H6V4Zm8 0v5h5';
const selected = ref('workspace');
const items = ref([
	{
		id: 'workspace',
		label: 'Workspace',
		icon: folderIcon,
		open: true,
		children: [
			{ id: 'remote-pages', label: 'Remote pages', icon: folderIcon, lazy: true },
			{ id: 'remote-components', label: 'Remote components', icon: folderIcon, lazy: true },
		],
	},
]);

function updateNode(nodes, id, patch) {
	for (const node of nodes) {
		if (node.id === id) {
			Object.assign(node, patch);
			return true;
		}
		if (Array.isArray(node.children) && updateNode(node.children, id, patch)) return true;
	}
	return false;
}

function loadChildren({ value }) {
	updateNode(items.value, value, { loading: true });
	setTimeout(() => {
		updateNode(items.value, value, {
			loading: false,
			lazy: false,
			children: [
				{ id: `${value}-overview`, label: 'Overview.vue', icon: fileIcon },
				{ id: `${value}-settings`, label: 'Settings.vue', icon: fileIcon },
				{ id: `${value}-history`, label: 'History.vue', icon: fileIcon },
			],
		});
	}, 900);
}
</script>

<template>
	<div class="w-full max-w-md">
		<DomTreeView
			v-model="selected"
			v-model:items="items"
			@load-children="loadChildren"
		/>
		<p class="mt-3 text-xs text-muted-fg">Open a remote folder to simulate a server lookup.</p>
	</div>
</template>

Architecture

Working with stores

DomTreeView emits intent: selection, toggles, actions, lazy-load requests, and reorder results. For small local trees, use v-model:items. For a larger editor, keep the real source of truth in Pinia and decide in the store whether a drop is allowed, persisted, or transformed.

When a valid drag hovers over a collapsed branch, the branch opens after dragExpandDelay. This matches file explorers and layers panels where users pause over a folder/group before dropping inside nested content.

Context menus follow the same pattern: TreeView emits a generic context-menu intent with kind, item, and node. Your app can pass that payload to DomContextMenu and decide whether a folder, file, DOM node, layer, or empty tree space should receive different actions.

The generic tree helpers are exported separately so a store can flatten visible rows, build an id index, and move nested items without mounting the component. A normalized store can still use the same event shape, then implement its own move operation against byId and child id arrays.

import { defineStore } from 'pinia';
import { computed, ref } from 'vue';
import { flattenTreeItems, indexTreeItems, moveTreeItem } from '@getdom/studio/vue';

export const useLayersStore = defineStore('layers', () => {
	const items = ref([]);
	const openValues = ref(new Set());
	const selectedId = ref('');

	const visibleRows = computed(() => flattenTreeItems(items.value, openValues.value));
	const byId = computed(() => indexTreeItems(items.value));

	function moveLayer(sourceId, targetId, position) {
		const moved = moveTreeItem(items.value, sourceId, targetId, position);
		if (moved) items.value = moved.items;
	}

	return { items, openValues, selectedId, visibleRows, byId, moveLayer };
});

Utilities

Tree helpers

The tree utilities are plain functions exported from @getdom/studio/vue. They are deliberately separate from the component so a Pinia store, editor engine, or server sync layer can use the same rules without mounting a TreeView.

Use these helpers when your source data is a nested array. If your app keeps a normalized store, use the same event shape from TreeView and apply the change inside the store instead.

treeItemValue(item)

Returns the stable address for an item. Prefer id in production; value and label are supported for simple demos.

treeItemLabel(item)

Returns the display label from label, name, or the item value.

treeItemChildren(item)

Safely returns children as an array so callers do not need null checks.

visitTreeItems(items, callback)

Walks every nested item. Useful for migrations, permissions, collecting ids, or seeding state.

seedTreeOpenValues(items, controlled, previous)

Creates the open branch set from controlled openValues, item.open flags, and previous local state.

treeItemHasChildren(item)

Returns true for items with children or lazy nodes that can load children later.

treeItemCanAcceptChildren(item)

Honours acceptsChildren and decides whether the middle drop zone is allowed.

flattenTreeItems(items, openValues)

Converts a nested tree into visible rows with depth, parent, path, open, loading, and expandable metadata.

indexTreeItems(items)

Builds a Map keyed by item id/value for fast lookup of item, parent, and path.

cloneTreeItems(items)

Clones nested tree arrays before a move so the source data is not mutated.

moveTreeItem(items, source, target, position)

Returns a new nested tree after moving one item before, inside, or after another item.

isSameOrDescendantPath(path, possibleParentPath)

Prevents invalid drops, such as moving a parent into one of its own descendants.

Data

Database-backed trees

Database trees are usually normalized already: each row has an id, a parent id, and often a sort order. Keep that shape in your database and store. Derive the nested items only where the TreeView needs to render.

When a drop happens, treat the TreeView event as an instruction. Update the moved row's parent id and sibling order in your store or API, then let the derived tree refresh. That keeps the visual component from owning persistence rules.

// Database rows are usually already normalized.
// The tree can be derived at the edge of your app, while the database/store
// remains the source of truth.
const rows = [
	{ id: 'root', parent_id: null, label: 'Project' },
	{ id: 'hero', parent_id: 'root', label: 'Hero section' },
	{ id: 'cta', parent_id: 'hero', label: 'CTA button' },
];

function rowsToTree(rows) {
	const byId = new Map(rows.map((row) => [row.id, { ...row, children: [] }]));
	const roots = [];

	for (const item of byId.values()) {
		if (item.parent_id && byId.has(item.parent_id)) {
			byId.get(item.parent_id).children.push(item);
		} else {
			roots.push(item);
		}
	}

	return roots;
}

function applyDropToRows(rows, { item, target, position }) {
	const next = rows.map((row) => ({ ...row }));
	const moved = next.find((row) => row.id === item.id);
	const targetRow = next.find((row) => row.id === target.id);
	if (!moved || !targetRow) return rows;

	// A real app would also update sort_order for siblings.
	moved.parent_id = position === 'inside' ? targetRow.id : targetRow.parent_id;
	return next;
}

Reference

Props

Control props

NameTypeTSDefaultDescription
modelValuestring | numberstring''Selected node value. Update it programmatically to highlight a node from another part of the UI.
items*
const items = [
	{
		id: 'src',
		label: 'src',
		type: 'folder',
		open: true,
		children: [
			{ id: 'src-index', label: 'Index.vue', type: 'file' },
		],
	},
];
arrayArray<TreeItem
type TreeItem = {
	id?: string | number;
	value?: string | number;
	label?: string;
	name?: string;
	icon?: string;
	rightIcon?: string;
	rightIconAction?: string;
	rightIconLabel?: string;
	open?: boolean;
	lazy?: boolean;
	loading?: boolean;
	disabled?: boolean;
	draggable?: boolean;
	acceptsChildren?: boolean;
	slot?: string;
	actions?: Array<TreeItemAction
>; children?: Array<TreeItem
>; };
>
Nested tree items. Use children for branches, lazy for async loading, and acceptsChildren: false to lock drops into a node.
openValuesarrayArray<unknown>[]Controlled open branch values. When empty, item.open seeds the initial state.
hoveredValuestring | numberstring''Value of a node currently hovered from another surface, such as a stage canvas.
draggablebooleanbooleantrueAllow nodes to be reordered by drag and drop.
externalDragValuestring | numberstring''Tree item value currently being dragged from another surface, such as a stage canvas.
externalDropTargetValuestring | numberstring''Tree item value currently being previewed as a drop target from another surface.
externalDropPosition'before' | 'inside' | 'after'string''Preview position for externalDropTargetValue.
externalDropTypesarrayArray<unknown>[]DataTransfer MIME types accepted from outside the tree.
externalDropEffect'copy' | 'move' | 'link'string'copy'Drop effect shown for accepted external drags.
dragExpandDelaynumbernumber800Delay in milliseconds before a collapsed branch opens while dragging over it. Set to 0 to disable.
canDropItemfunctionFunctionOptional predicate for tree-item drops. Return false to block a move.
canDropExternalfunctionFunctionOptional predicate for external drops. Return false to block the drop target.
scrollIntoViewbooleanbooleantrueScroll the selected node into view when modelValue changes programmatically.
density'compact' | 'comfortable'string'compact'Vertical spacing for tree rows.
toggleTransitionbooleanbooleantrueAnimate rows in and out when branches open and close.
variant'default' | 'finder'string'default'Visual treatment for the tree surface.
chromebooleanbooleantrueRender the outer border, padding, radius, and shadow around the tree.
labelstringstring'Tree view'Accessible name for the tree.

Auto-generated from Tree view.props and inline _edit hints.

Events

NamePayloadDescription
@update:modelValue(value
value: string | number;
: string | number)
Emitted when the selected node changes.
@update:items(items
type TreeItems = Array<TreeItem
>;
: TreeItems)
Emitted with the reordered tree so callers can sync v-model:items.
type TreeItems = Array<TreeItem
>; const items = [ { id: 'src', label: 'src', type: 'folder', open: true, children: [ { id: 'src-index', label: 'Index.vue', type: 'file' }, ], }, ];
@update:openValues(payload
payload: unknown;
: unknown)
@select({ item
type TreeItem = {
	id?: string | number;
	value?: string | number;
	label?: string;
	name?: string;
	icon?: string;
	rightIcon?: string;
	rightIconAction?: string;
	rightIconLabel?: string;
	open?: boolean;
	lazy?: boolean;
	loading?: boolean;
	disabled?: boolean;
	draggable?: boolean;
	acceptsChildren?: boolean;
	slot?: string;
	actions?: Array<TreeItemAction
>; children?: Array<TreeItem
>; };
, value
value: string | number;
, node
type TreeNode = {
	item: TreeItem
; value: string | number; label: string | number; depth: number; parent: TreeNode
| null; path: Array<number>; open: boolean; loading: boolean; expandable: boolean; };
})
Fired when a tree item is selected.
@hover({ item
type TreeItem = {
	id?: string | number;
	value?: string | number;
	label?: string;
	name?: string;
	icon?: string;
	rightIcon?: string;
	rightIconAction?: string;
	rightIconLabel?: string;
	open?: boolean;
	lazy?: boolean;
	loading?: boolean;
	disabled?: boolean;
	draggable?: boolean;
	acceptsChildren?: boolean;
	slot?: string;
	actions?: Array<TreeItemAction
>; children?: Array<TreeItem
>; };
, value
value: string | number;
, node
type TreeNode = {
	item: TreeItem
; value: string | number; label: string | number; depth: number; parent: TreeNode
| null; path: Array<number>; open: boolean; loading: boolean; expandable: boolean; };
})
Fired when a pointer enters a tree row.
@hover-end({ item
type TreeItem = {
	id?: string | number;
	value?: string | number;
	label?: string;
	name?: string;
	icon?: string;
	rightIcon?: string;
	rightIconAction?: string;
	rightIconLabel?: string;
	open?: boolean;
	lazy?: boolean;
	loading?: boolean;
	disabled?: boolean;
	draggable?: boolean;
	acceptsChildren?: boolean;
	slot?: string;
	actions?: Array<TreeItemAction
>; children?: Array<TreeItem
>; };
, value
value: string | number;
, node
type TreeNode = {
	item: TreeItem
; value: string | number; label: string | number; depth: number; parent: TreeNode
| null; path: Array<number>; open: boolean; loading: boolean; expandable: boolean; };
})
Fired when a pointer leaves a tree row.
@action({ action
type TreeItemAction = {
	value?: string;
	label?: string;
	icon?: string;
	disabled?: boolean;
};
, item
type TreeItem = {
	id?: string | number;
	value?: string | number;
	label?: string;
	name?: string;
	icon?: string;
	rightIcon?: string;
	rightIconAction?: string;
	rightIconLabel?: string;
	open?: boolean;
	lazy?: boolean;
	loading?: boolean;
	disabled?: boolean;
	draggable?: boolean;
	acceptsChildren?: boolean;
	slot?: string;
	actions?: Array<TreeItemAction
>; children?: Array<TreeItem
>; };
, value
value: string | number;
, node
type TreeNode = {
	item: TreeItem
; value: string | number; label: string | number; depth: number; parent: TreeNode
| null; path: Array<number>; open: boolean; loading: boolean; expandable: boolean; };
})
Fired when a right-side item action is clicked.
@context-menu(payload
type TreeContextMenuPayload = {
	event: MouseEvent;
	kind: 'item' | 'empty';
	item: TreeItem
| null; value: string | number | null; node: TreeNode
| null; };
: TreeContextMenuPayload)
Fired when a tree row or empty tree space is right-clicked. Use kind to distinguish item from empty context.
type TreeContextMenuPayload = {
	event: MouseEvent;
	kind: 'item' | 'empty';
	item: TreeItem
| null; value: string | number | null; node: TreeNode
| null; }; type TreeItem
= { id?: string | number; value?: string | number; label?: string; name?: string; icon?: string; rightIcon?: string; rightIconAction?: string; rightIconLabel?: string; open?: boolean; lazy?: boolean; loading?: boolean; disabled?: boolean; draggable?: boolean; acceptsChildren?: boolean; slot?: string; actions?: Array<TreeItemAction
>; children?: Array<TreeItem
>; }; type TreeItemAction
= { value?: string; label?: string; icon?: string; disabled?: boolean; }; type TreeNode
= { item: TreeItem
; value: string | number; label: string | number; depth: number; parent: TreeNode
| null; path: Array<number>; open: boolean; loading: boolean; expandable: boolean; }; const rowContext = { event: new MouseEvent('contextmenu'), kind: 'item', item: { id: 'src-pages-docs', label: 'DomStudioLayout.vue', type: 'file' }, value: 'src-pages-docs', node: { item: { id: 'src-pages-docs', label: 'DomStudioLayout.vue', type: 'file' }, value: 'src-pages-docs', label: 'DomStudioLayout.vue', depth: 3, parent: null, path: [0, 0, 0], open: false, loading: false, expandable: false, }, }; const emptyContext = { event: new MouseEvent('contextmenu'), kind: 'empty', item: null, value: null, node: null, };
@toggle({ item
type TreeItem = {
	id?: string | number;
	value?: string | number;
	label?: string;
	name?: string;
	icon?: string;
	rightIcon?: string;
	rightIconAction?: string;
	rightIconLabel?: string;
	open?: boolean;
	lazy?: boolean;
	loading?: boolean;
	disabled?: boolean;
	draggable?: boolean;
	acceptsChildren?: boolean;
	slot?: string;
	actions?: Array<TreeItemAction
>; children?: Array<TreeItem
>; };
, value
value: string | number;
, open
open: boolean;
})
Fired when a branch is opened or closed.
@load-children({ item
type TreeItem = {
	id?: string | number;
	value?: string | number;
	label?: string;
	name?: string;
	icon?: string;
	rightIcon?: string;
	rightIconAction?: string;
	rightIconLabel?: string;
	open?: boolean;
	lazy?: boolean;
	loading?: boolean;
	disabled?: boolean;
	draggable?: boolean;
	acceptsChildren?: boolean;
	slot?: string;
	actions?: Array<TreeItemAction
>; children?: Array<TreeItem
>; };
, value
value: string | number;
})
Fired when a lazy node is opened and needs children.
@drag-preview({ item
type TreeItem = {
	id?: string | number;
	value?: string | number;
	label?: string;
	name?: string;
	icon?: string;
	rightIcon?: string;
	rightIconAction?: string;
	rightIconLabel?: string;
	open?: boolean;
	lazy?: boolean;
	loading?: boolean;
	disabled?: boolean;
	draggable?: boolean;
	acceptsChildren?: boolean;
	slot?: string;
	actions?: Array<TreeItemAction
>; children?: Array<TreeItem
>; };
, value
value: string | number;
, node
type TreeNode = {
	item: TreeItem
; value: string | number; label: string | number; depth: number; parent: TreeNode
| null; path: Array<number>; open: boolean; loading: boolean; expandable: boolean; };
, position
type TreeDropPosition = 'before' | 'inside' | 'after';
, kind
type TreeDropKind = 'tree' | 'external';
})
Fired while a valid drag target is being previewed.
@drag-preview-end({ value
value: string | number;
, position
type TreeDropPosition = 'before' | 'inside' | 'after';
, kind
type TreeDropKind = 'tree' | 'external';
})
Fired when the current drag preview is cleared.
@reorder({ items
type TreeItems = Array<TreeItem
>;
, item
type TreeItem = {
	id?: string | number;
	value?: string | number;
	label?: string;
	name?: string;
	icon?: string;
	rightIcon?: string;
	rightIconAction?: string;
	rightIconLabel?: string;
	open?: boolean;
	lazy?: boolean;
	loading?: boolean;
	disabled?: boolean;
	draggable?: boolean;
	acceptsChildren?: boolean;
	slot?: string;
	actions?: Array<TreeItemAction
>; children?: Array<TreeItem
>; };
, sourceValue
sourceValue: string | number;
, sourceNode
type TreeNode = {
	item: TreeItem
; value: string | number; label: string | number; depth: number; parent: TreeNode
| null; path: Array<number>; open: boolean; loading: boolean; expandable: boolean; };
, target
type TreeItem = {
	id?: string | number;
	value?: string | number;
	label?: string;
	name?: string;
	icon?: string;
	rightIcon?: string;
	rightIconAction?: string;
	rightIconLabel?: string;
	open?: boolean;
	lazy?: boolean;
	loading?: boolean;
	disabled?: boolean;
	draggable?: boolean;
	acceptsChildren?: boolean;
	slot?: string;
	actions?: Array<TreeItemAction
>; children?: Array<TreeItem
>; };
, targetValue
targetValue: string | number;
, targetNode
type TreeNode = {
	item: TreeItem
; value: string | number; label: string | number; depth: number; parent: TreeNode
| null; path: Array<number>; open: boolean; loading: boolean; expandable: boolean; };
, position
type TreeDropPosition = 'before' | 'inside' | 'after';
})
Fired after drag/drop with a reordered items array.
@external-drop({ event
event: DragEvent;
, dataTransfer
dataTransfer: DataTransfer | null;
, types
types: Array<string>;
, getData
getData: (type: string) => string;
, item
type TreeItem = {
	id?: string | number;
	value?: string | number;
	label?: string;
	name?: string;
	icon?: string;
	rightIcon?: string;
	rightIconAction?: string;
	rightIconLabel?: string;
	open?: boolean;
	lazy?: boolean;
	loading?: boolean;
	disabled?: boolean;
	draggable?: boolean;
	acceptsChildren?: boolean;
	slot?: string;
	actions?: Array<TreeItemAction
>; children?: Array<TreeItem
>; };
, value
value: string | number;
, node
type TreeNode = {
	item: TreeItem
; value: string | number; label: string | number; depth: number; parent: TreeNode
| null; path: Array<number>; open: boolean; loading: boolean; expandable: boolean; };
, position
type TreeDropPosition = 'before' | 'inside' | 'after';
})
Fired when accepted data from outside the tree is dropped onto a row.

Names auto-detected from defineEmits and source emit() calls; payload and description from __doc.events when present.

Keyboard

  • ↑ / ↓Move focus through visible items.
  • Open a closed branch, otherwise move to the first child.
  • Close an open branch, otherwise move to the parent.
  • Enter / SpaceSelect the focused item.
  • Home / EndMove to the first or last visible item.
  • Alt + ↑ / ↓Move the focused item before or after a sibling when draggable is enabled.