Tree
A tree view displays hierarchical list of items.
Examples
🖱️ Click mode
By default, clicking a node only selects it (i.e. highlights it visually). To expand or check, you must click on the toggle icon or checkbox, respectively.
You can change this behavior via the clickMode prop. Currently, it's accepting 3 values:
undefined: default, only selectsexpand: selects first, then toggles expandcheck: selects first, then toggles check
Keyboard Interactions: the mode also applies to the Enter key!
✅ Check
Enable checkboxes in your tree for use cases like file selection, permission management, or multi-item actions by setting checkable: true. The checked states are managed through 2 v-model: checkedKeys and indeterminateKeys.
Cascading behavior
To enable hierarchical cascading, set checkCascade: true. With this enabled:
- A parent node is checked only when all of its children are checked
- A parent node is indeterminate when only some of its children are checked
- A parent node is unchecked when none of its children are checked
Check strategies
When cascading is enabled, Tree provides 3 check strategies that control which nodes appear in checkedKeys:
all: Collects all checked nodes.parent: Collects only the highest-level checked nodes.child: Collects only the lowest-level checked nodes (leaf nodes).
Example
Given a tree where Folder A contains File 1 (checked) and File 2 (checked):
☑ Folder A <- parent is auto-checked because all children are checked
☑ File 1 <- child
☑ File 2 <- childThe checkedKeys will be:
all:['Folder A', 'File 1', 'File 2']parent:['Folder A']child:['File 1', 'File 2']
Advanced: moving nodes within a checkable Tree
Tree automatically sync the checked states for internal moving operations (e.g. Drag & Drop). For external moving operations (e.g. programmatic moves), use the exposed function syncCheckedStatesAfterMoved() to sync correctly.
Sync mechanism
Let's say a node N is moved from a source parent to a destination parent. The children of them (excluding N) are called Src Children and Dest Children respectively.
- ✅: all nodes are checked
-: some nodes are unchecked
| Strategy | Before | After | ||||
|---|---|---|---|---|---|---|
N | Src Children | Dest Children | N | Src Children | Dest Children | |
'all' | 23 = 8 states | Preserved | ||||
'child' | 23 = 8 states | Preserved | ||||
'parent' | ✅ | ✅ | - | - | Preserved | |
| - | ✅ / - | ✅ | ✅ | Preserved | ||
5 other states | Preserved | |||||
⌨️ Keyboard Interactions
Tree automatically handles keys in the following table. You can bind them to your desired elements via the keyboardTargets prop so that when they are focused, the keys will be handled. Tree also detects whether the passed targets are <input> and will ignore the unsupported keys.
🚧 Careful when passing the targets
Wrap keyboardTargets in a computed to prevent unnecessary re-creation on tree re-renders (expand/collapse). Direct array literals in templates will cause focus loss.
| Key | Description | <input> supported? |
|---|---|---|
| ArrowDown | When focus is on a node, moves focus to the next one. | ✅ |
| ArrowUp | When focus is on a node, moves focus to the previous one. | ✅ |
| PageDown | When focus is on a node, moves focus to the next 10 nodes. | ✅ |
| PageUp | When focus is on a node, moves focus to the previous 10 nodes. | ✅ |
| Home | Moves focus to the first node. | ❌ |
| End | Moves focus to the last node. | ❌ |
| Escape | Moves focus to the container. | ✅ |
| Enter | When focus is on a node, toggles select on it and (optionally) trigger clickMode. | ✅ |
| Space | When focus is on a node, toggles check on it. | ❌ |
| ArrowRight | When focus is on a non-leaf node: if it's collapsed, expand the node; if it's opened, moves focus to the first child. | ❌ |
| ArrowLeft | When focus is on a non-leaf node and it's expanded, collapse the node. Otherwise, moves focus to its parent. | ❌ |
Following is an example to interact with Tree while typing on the input:
🔍 Search
Tree provides powerful search capabilities that automatically expand matching nodes, scroll to the first match, and focus it for easy keyboard interactions.
Basic search
Simply provide the searchText prop to enable substring matching. By default, Tree searches against each node's label.
<HTree
v-model:nodes="nodes"
:search-text
/>Customize matching logic
Use the searchFilter prop to control how nodes are matched. This is useful when you want to search by custom properties or implement case-sensitive matching:
<HTree
v-model:nodes="nodes"
:search-text
:search-filter="(node, searchText) => {
// Search by both label and description
return node.label.includes(searchText) || node.description?.includes(searchText)
}"
/>Unmatched nodes visibility
By default, unmatched nodes remain visible but with their highest-level ancestors collapsed. This allows users to quickly jump to a match and then explore nearby nodes.
To completely hide unmatched nodes, set searchHideUnmatched: true:
<HTree
v-model:nodes="nodes"
:search-text
search-hide-unmatched
/>Advanced: Stemming with hierarchical search
What is stemming?
Stemming reduces words to their root form, allowing fuzzy matches. For example:
- Searching "run" matches "running", "runner", "runs"
- Searching "develop" matches "developer", "development", "developing"
This creates a more forgiving search experience, especially useful for large trees where users might not know the exact terminology.
When stemming is enabled, the search also becomes "hierarchical":
- The search text is split into tokens
- A node is matched only when it matches at least 1 search token
- Remaining tokens will match against the node ancestors to:
- Help jump to the most relevant match (when unmatched nodes are shown)
- OR help narrow down results by filtering based on ancestors (when unmatched nodes are hidden)
<HTree
v-model:nodes="nodes"
:search-text
search-stem-index
/>Tree will automatically generate a stem index from the passed nodes.
What is a stem index?
A stem index is an inverted index that maps word stems to their matching nodes. It improves search performance for large trees by avoiding the need to tokenize and stem all nodes on every search.
The index usually stores metadata about each match (like the original word forms and their positions) so that search results can be highlighted in the UI with the exact terms users searched for, not just the stems.
Custom stem index
You can pre-compute and provide your own stem index for greater controls:
<script setup lang="ts">
import { ref } from 'vue'
import { collectStemTokenIndex } from '@holistics/design-system'
const nodes = ref([/* some nodes */])
const searchText = ref('')
const searchStemIndex = collectStemTokenIndex(nodes)
</script>
<template>
<HTree
v-model:nodes="nodes"
:search-text
:search-stem-index
/>
</template>Advanced: Custom search algorithm
For extremely advanced use cases (e.g. regex search, multi-field weighted search, or integration with external search engines), you can provide a custom search function to the search prop. Your function must return a TreeSearchResult object.
TreeSearchResult
export interface TreeSearchResult<N extends TreeNode<N>> {
/**
* Matched nodes.
*/
nodes: N[]
/**
* Matched keys.
*/
keys: TreeNodeKey[]
/**
* List of expanded keys after searching.
*/
expandedKeys: TreeNodeKey[]
/**
* Map of stemming-matched node key to their original tokens (usually used for highlighting).
*/
stemmingMatchedTokensMap?: Record<TreeNodeKey, string[]>
}↕️ Drag & Drop
Enable drag & drop by setting the draggable prop:
<HTree
v-model:nodes="nodes"
draggable
@node-move="handleMove"
/>Drag Image
Put the DragImageProvider component anywhere above Tree to display a better image while dragging! You can even change it to your likings, too!
<script setup lang="ts">
import { Ref, ref } from 'vue'
import { HTree, type TreeNodeBase, HDragImageProvider } from '@holistics/design-system'
const nodes = ref(Array.from({ length: 100 }, (_, i) => ({
key: `${i}. File`,
label: `${i}. File`,
}))) as Ref<TreeNodeBase[]>
</script>
<template>
<HDragImageProvider>
<HTree
v-model:nodes="nodes"
draggable
/>
</HDragImageProvider>
</template>Auto Scroll
Tree supports auto scroll when dragging near the top/bottom (aka. "edges") of the container. By default, the edges' size is 3 default node size = 28 * 3 = 84px. You will want to customize this number (via the dragAutoScrollEdgeSize prop) when the node size is different than 28px.
There is also a "dampening" mechanism to mimic natural acceleration when auto-scrolling. There are 2 types of dampening combined:
- ⌛️ Time dampening: Auto-scrolling speed increases with the dragging duration.
- 🛣️ Distance dampening: As the user moves the mouse toward the edges, the proximity to the container rectangle increases the auto-scrolling speed. Upon reaching a specific point (known as the "buffer") the auto-scroll accelerates to its maximum speed. You can change this buffer via the
dragAutoScrollDampeningBufferprop (default is1 default node size = 28px).
Interact with other components
The Drag & Drop implementation for Tree is based on HTML5 Drag & Drop API. Therefore you could easily leverage them to interact with other external components!
Following is an example of building a Text Editor with the abilities to drag a node from Tree to the editor to split them in half, or to re-order the tabs.
⏳️ Async Loading
Load tree nodes on-demand to improve performance for large datasets or when fetching data from APIs.
To mark a node as "unloaded", set isLeaf: false and leave children as undefined. When the user expands the node, the @node-load event fires, allowing you to fetch and populate its children.
🔢 Sorting
You can provide a sorting function to the sort prop to keep nodes sorted alphabetically, by type, or any custom order. When enabled, all children under the same parent will be sorted according to your function.
👉️ Automatic vs. Manual Sorting
Automatic sorting applies to internal operations that happen inside the Tree component:
- Drag & Drop: When you drag a node to a new location, it's automatically inserted in the correct sorted position
- Other internal mutations within the component
Manual sorting required for external operations that happen outside the Tree component:
- Initial data: You must sort your
nodesarray before passing it to theTree - Async loading: When fetching children from an API, sort them before assigning to
node.children - External updates: Any changes you make to the
nodesarray
Why this distinction?
Automatically sorting all nodes recursively on every change would be extremely expensive since any node could contain deeply nested children.
The Tree component only sorts children of the same parent when an internal operation affects them. For external changes, you have better control over when and what to sort, avoiding unnecessary re-sorting of unchanged branches.
Utilities for Async Loading
Design System provides utility functions to help working with async loading on a sorted Tree:
import { TreeNodeBase, TreeLoadAction, insertSorted, mapReplaceSorted, } from '@holistics/design-system'
async function onNodeLoad(node: TreeNodeBase, action: TreeLoadAction) {
const newChildren = await callApi(node)
newChildren.sort(sortFn)
if (node.children) {
if (action === 'load') {
// First load: existing `node.children` came from synchronous operations (e.g. Drag & Drop)
// => Insert fetched children in a sorted order
insertSorted(newChildren, node.children, sortFn, true)
} else {
// Reload: existing `node.children` was loaded before, but `newChildren` could have new and/or removed nodes
// => Preserve the same-`key` nodes to avoid wiping out their descendants.
mapReplaceSorted(newChildren, node.children, (a, b) => a.key === b.key)
}
}
node.children = newChildren
}Drag & Drop behavior
When sorting is enabled, nodes are expected to be in a particular order. The drag & drop visual guides (highlights and drop indicators) reflect this by showing only valid sorted insertion points.
⚡ Virtual List
Enable virtualization for trees with 1000+ nodes to improve rendering performance. Only visible nodes are rendered in the DOM.
Simply set a fixed/max height to the Tree container and pass the virtualList prop:
Legacy Virtual List
By default, Tree will use an optimized version of Virtual List when virtualListEstimateSize is a static number and virtualListDynamicSize is false. That version will eventually support all the features like the old version. For now, if you want to force using the old version, set virtualList to 'legacy'.
🔗 Links
Tree supports Vue Router out of the box so you can render individual nodes as <RouterLink>.
First, make sure vue-router is installed:
npm add vue-routerpnpm add vue-routeryarn add vue-routerThen, specify the to prop for nodes that you want to be links.
External links
If to is a string starting with http, the node will be treated as an external link and will automatically have target="_blank" alongside rel="noopener noreferrer" set on the <a> element.
🎨 Styling
Quickly set your preferred styles for different parts and states by using the nodeStyles props. You can also use the CSS class names to customize different parts, but it doesn't contain any state information (for now).
Default node styles
You can access the default node styles via DEFAULT_NODE_STYLES from @holistics/design-system. Here is the default value:
{
"container": {
"base": "flex select-none items-start rounded py-1.5 pr-2 focus:outline-none",
"hovered": "hover:bg-gray-200",
"selected": "bg-blue-50",
"multiSelectRangedCursor": "ring-2 ring-blue-200 ring-inset",
"dragging": "bg-gray-300 opacity-40",
"focused": "bg-gray-200",
"contextMenuOpened": "bg-gray-200",
"disabled": {
"true": "cursor-not-allowed",
"false": "cursor-pointer"
}
},
"toggle": {
"base": "flex items-center justify-center h-4",
"errorTooltip": "!text-red-400",
"error": "text-red-600",
"loading": "cursor-wait text-gray-700",
"hovered": "hover:text-blue-600",
"expanded": "rotate-90",
"disabled": "opacity-40"
},
"checkbox": {
"base": "flex size-3 items-center justify-center rounded-sm border text-white",
"hovered": "hover:border-blue-600",
"unchecked": "border-gray-400 bg-white",
"checked": "border-blue-600 bg-blue-600",
"indeterminate": "border-blue-600 bg-blue-600",
"disabled": "opacity-40 cursor-not-allowed"
},
"content": {
"base": "flex items-start overflow-hidden",
"disabled": "opacity-40"
},
"dragOverGuide": {
"parent": "absolute inset-0 bg-blue-50/40",
"inside": "absolute right-0 inset-y-0 rounded border-2 border-blue-300",
"before": "absolute right-0 -top-px rounded h-0.5 bg-blue-300",
"after": "absolute right-0 -bottom-px rounded h-0.5 bg-blue-300",
"siblings": "absolute right-0 inset-y-0 bg-blue-50/40"
}
}Customize connection line colors
Currently, all parts and their states can be customized via the nodeStyles prop, except the lines (i.e. from the showLine prop). For performance reason, you must use CSS vars to customize their colors. Here are the vars and their default values:
:root {
--hui-tree-node-line-color: theme(colors.gray.300);
--hui-tree-node-line-color-hovered: theme(colors.blue.500);
--hui-tree-node-line-color-active: theme(colors.gray.500);
}🧩 Slots
You have many options for slot customizing, including global slots (i.e. applying for all nodes) & individual slots.
Following is an example of using individual slot to have a rename input in-place where the user hit the "Rename" option in the context menu.
WARNING
Be careful when using slots with Virtual List as the slots could be unmounted when not in scroll view. The example also demonstrate a way (not the best tho) to control the states when this happens.
🏷️ Generics
Tree supports generic types by looking at the nodes prop so that all the emitted events and other props referencing nodes will have the passed type.
<script setup lang="ts">
import { ref, type Ref } from 'vue'
import { HTree, type TreeNode } from '@holistics/design-system'
interface MyTreeNode extends TreeNode<MyTreeNode> {
// ^ Your node ^ `children` of your node
canDelete?: boolean
}
const nodes = ref([]) as Ref<MyTreeNode[]>
</script>
<template>
<HTree
v-model:nodes="nodes"
:sort="(nodeA, nodeB) => {
// `nodeA` & `nodeB` will have type `MyTreeNode`
}"
@node-load="(node) => {
// `node` will have type `MyTreeNode`
if (node.canDelete) {
// ...
}
}"
/>
</template>Fixed depth via recursive generic type
You can describe your tree structure via recursive generic type to get the best type-safety and convenience.
Example
// A Dataset can only contain Data Models & is always be a parent node
interface DatasetNode extends TreeNode<DataModelNode> {
type: 'dataset'
icon: 'data-set'
children: DataModelNode[]
}
// A Data Model can only contain Fields & is always be a parent node
interface DataModelNode extends TreeNode<Field> {
type: 'data-model'
icon: 'data-model'
children: Field[]
}
// A Field is always a leaf node
interface FieldNode extends TreeNode<never> {
type: 'field'
icon: 'type/string' | 'type/number' | 'type/boolean' | 'type/date' | 'type/timestamp' | 'type/unknown'
children?: never
}Forcing generic type
Since Vue components are just functions, you can force a generic type when using Tree.
Example
<script setup lang="ts">
import { _HTree, type TreeNode } from '@holistics/design-system'
interface ForcedTreeNode extends TreeNode<ForcedTreeNode> {
someKey?: boolean
}
const HTree = _HTree<ForcedTreeNode>
</script>
<template>
<HTree
@node-load="(node) => {
// `node` will have type `ForcedTreeNode` even though `nodes` prop is not specified`
}"
/>
</template>API
Pass-through: <div>
What does this mean?
All props, events, and attrs that are not specified in the tables below will be passed to the element/component described above.