Tree
Browse, select and check hierarchical content.
Browse and select
import { Space, Text, Tree } from '@ifhover/leaf-ui';
import { useState } from 'react';
import { treeData } from './data';
export function TreeBasic() {
const [selected, setSelected] = useState<readonly string[]>([]);
return (
<Space direction="vertical" align="stretch">
<Tree
data={treeData}
defaultExpandedKeys={['design']}
showIcon
selectedKeys={selected}
onSelect={setSelected}
/>
<output>
<Text tone="muted">Selected: {selected.join(', ') || 'None'}</Text>
</output>
</Space>
);
}
Linked checks
import { Space, Text, Tree } from '@ifhover/leaf-ui';
import { useState } from 'react';
import { treeData } from './data';
export function TreeCheckable() {
const [checked, setChecked] = useState<readonly string[]>(['components']);
return (
<Space direction="vertical" align="stretch">
<Tree
data={treeData}
checkable
selectable={false}
defaultExpandAll
showLine
checkedKeys={checked}
onCheck={setChecked}
/>
<output>
<Text tone="muted">Checked keys: {checked.join(', ') || 'None'}</Text>
</output>
</Space>
);
}
Independent checks
Parents and children are checked independently.
import { Tree } from '@ifhover/leaf-ui';
import { treeData } from './data';
export function TreeStrict() {
return <Tree data={treeData} checkable checkStrictly selectable={false} defaultExpandAll />;
}
Search nodes
import { Input, Space, Tree } from '@ifhover/leaf-ui';
import { Search } from 'lucide-react';
import { useState } from 'react';
import { treeData } from './data';
export function TreeSearch() {
const [query, setQuery] = useState('');
return (
<Space direction="vertical" align="stretch">
<Input
aria-label="Search nodes"
placeholder="Search nodes"
prefix={<Search />}
value={query}
onChange={(event) => setQuery(event.target.value)}
allowClear
/>
<Tree data={treeData} searchValue={query} showIcon />
</Space>
);
}
Extended features
Drag a handle to the upper, middle or lower part of a target row to place a node before, inside or after it. With the keyboard, Space picks up or drops, Up/Down chooses a target, Left/Right places before/after it, and Escape cancels. Update data with the new tree returned by onDrop.
Expand Projects to load children. Move nodes using their handles.
10000 nodes, only visible rows are mounted.
import type { TreeNode } from '@ifhover/leaf-ui';
import { Space, Tree } from '@ifhover/leaf-ui';
import { useState } from 'react';
const initial: TreeNode[] = [
{ key: 'projects', title: 'Projects', isLeaf: false },
{ key: 'archive', title: 'Archive', children: [{ key: 'draft', title: 'Draft', isLeaf: true }] },
];
export function ExtensionDemo() {
const [data, setData] = useState(initial);
return (
<Space direction="vertical" align="stretch">
<p>Expand Projects to load children. Move nodes using their handles.</p>
<Tree
data={data}
loadData={async (node, signal) => {
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(resolve, 500);
signal.addEventListener(
'abort',
() => {
clearTimeout(timer);
reject(new DOMException('Aborted', 'AbortError'));
},
{ once: true },
);
});
return [
{ key: `${node.key}-design`, title: 'Design', isLeaf: true },
{ key: `${node.key}-dev`, title: 'Development', isLeaf: true },
];
}}
draggable
onDrop={(info) => setData(info.data)}
/>
<p>10000 nodes, only visible rows are mounted.</p>
<Tree
virtual
height={240}
data={Array.from({ length: 10000 }, (_, i) => ({
key: String(i),
title: `Node ${i + 1}`,
}))}
/>
</Space>
);
}
Refreshing asynchronous data
Update cacheKey to refresh lazy caches for the same node keys. Changing data or loadData also clears caches; keep their references stable across unrelated renders. Cancelled requests cannot overwrite the new data.
The root key stays stable. A new cache version discards old children and cancels pending requests. import { Button, Space, Text, Tree, type TreeNode } from '@ifhover/leaf-ui';
import { useCallback, useMemo, useState } from 'react';
export function TreeCacheRefresh() {
const [version, setVersion] = useState(1);
const data = useMemo<readonly TreeNode[]>(
() => [{ key: 'root', title: 'Documents', isLeaf: false }],
[true],
);
const loadData = useCallback(
async (node: TreeNode, signal: AbortSignal): Promise<readonly TreeNode[]> => {
await new Promise<void>((resolve) => {
const timer = setTimeout(resolve, 400);
signal.addEventListener(
'abort',
() => {
clearTimeout(timer);
resolve();
},
{ once: true },
);
});
if (signal.aborted) return [];
return [
{
key: `${node.key}-${version}`,
title: `${'Release'} ${version}`,
isLeaf: true,
},
];
},
[true, version],
);
return (
<Space direction="vertical" align="stretch">
<div>
<Button variant="outline" onClick={() => setVersion((value) => value + 1)}>
{'Refresh data'}
</Button>
</div>
<Tree
aria-label={'Versioned documents'}
data={data}
loadData={loadData}
cacheKey={version}
defaultExpandedKeys={['root']}
/>
<Text tone="muted">
{'The root key stays stable. A new cache version discards old children and cancels pending requests.'}
</Text>
</Space>
);
}
Tree API
TreeNode
TreeSelectInfo
TreeCheckInfo
TreeExpandInfo
Types
Import these types to define props or prepare data.
import type { TreeProps, TreeNode, TreeSelectInfo, TreeCheckInfo, TreeExpandInfo } from '@ifhover/leaf-ui';
Usage notes
Each node needs a unique, stable key. Example data:
import type { TreeNode } from '@ifhover/leaf-ui';
export const treeData: readonly TreeNode[] = [
{
key: 'design',
title: 'Design resources',
children: [
{ key: 'components', title: 'Component guidelines' },
{ key: 'colors', title: 'Brand colors' },
{ key: 'archive', title: 'Archived resources (disabled)', disabled: true },
],
},
{
key: 'engineering',
title: 'Engineering',
children: [
{ key: 'web', title: 'Website' },
{ key: 'mobile', title: 'Mobile app' },
],
},
];
Checking a parent checks enabled children; disabled nodes are unaffected. Partially checked children create a mixed parent. checkStrictly keeps checks independent. In multiple mode, clicking toggles selection; Shift selects a range of visible nodes.
Use up/down to move, right/left to expand/collapse, Home/End to jump, Enter to select and Space to check. Typing locates nodes. Search temporarily reveals matching paths; clearing it restores the original expansion.
Extension types
import type { TreeDropInfo } from '@ifhover/leaf-ui';
TreeDropInfo API
moveTreeNode
Move a node in a custom data operation. This pure function returns a new tree without changing its input. It returns null for missing targets, self moves or moves into descendants. Tree already includes the new data in its onDrop callback.
import { moveTreeNode } from '@ifhover/leaf-ui';
const next = moveTreeNode(data, 'design', 'team', 'inside');