Tree
Hierarchical list view with expand/collapse, single or multi-select, and full WAI-ARIA keyboard support.
Implements the WAI-ARIA tree pattern — roving tabindex, ArrowUp/Down/Left/Right navigation, Home/End jumps, and aria-expanded / aria-selected / aria-level on every node.
import { Tree, type TreeNode } from '@arshad-shah/cynosure-react';
const items: TreeNode[] = [
{
id: 'src',
label: 'src',
children: [
{
id: 'src/components',
label: 'components',
children: [
{ id: 'src/components/Button.tsx', label: 'Button.tsx' },
{ id: 'src/components/Input.tsx', label: 'Input.tsx' },
{ id: 'src/components/Card.tsx', label: 'Card.tsx' },
],
},
{
id: 'src/hooks',
label: 'hooks',
children: [
{ id: 'src/hooks/useId.ts', label: 'useId.ts' },
{ id: 'src/hooks/useTheme.ts', label: 'useTheme.ts' },
],
},
{ id: 'src/index.ts', label: 'index.ts' },
],
},
{ id: 'README.md', label: 'README.md' },
{ id: 'package.json', label: 'package.json' },
];
export default function Example() {
return (
<div style={{ maxWidth: 320 }}>
<Tree items={items} defaultExpandedIds={['src']} aria-label="Files" />
</div>
);
}
Default expanded
Section titled “Default expanded”Pass defaultExpandedIds to open a set of branches on first paint without controlling state.
import { Tree, type TreeNode } from '@arshad-shah/cynosure-react';
const items: TreeNode[] = [
{
id: 'docs',
label: 'docs',
children: [
{ id: 'docs/intro.md', label: 'intro.md' },
{ id: 'docs/install.md', label: 'install.md' },
],
},
{
id: 'src',
label: 'src',
children: [
{ id: 'src/index.ts', label: 'index.ts' },
{ id: 'src/utils.ts', label: 'utils.ts' },
],
},
];
export default function Example() {
return (
<div style={{ maxWidth: 280 }}>
<Tree items={items} defaultExpandedIds={['docs', 'src']} aria-label="Project files" />
</div>
);
}
Single selection
Section titled “Single selection”Pass selectionMode="single" (plus optional defaultSelectedIds) for a picker-style tree.
import { Tree, type TreeNode } from '@arshad-shah/cynosure-react';
const items: TreeNode[] = [
{
id: 'departments',
label: 'Departments',
children: [
{ id: 'engineering', label: 'Engineering' },
{ id: 'design', label: 'Design' },
{ id: 'marketing', label: 'Marketing' },
],
},
];
export default function Example() {
return (
<div style={{ maxWidth: 280 }}>
<Tree
items={items}
defaultExpandedIds={['departments']}
selectionMode="single"
defaultSelectedIds={['design']}
aria-label="Departments"
/>
</div>
);
}
Multi selection
Section titled “Multi selection”Pass selectionMode="multiple" for checkbox-style multi-select. Ctrl/Cmd-click toggles individual ids; aria-multiselectable flips to true on the root.
import { Tree, type TreeNode } from '@arshad-shah/cynosure-react';
const items: TreeNode[] = [
{
id: 'fruit',
label: 'Fruit',
children: [
{ id: 'apple', label: 'Apple' },
{ id: 'banana', label: 'Banana' },
{ id: 'cherry', label: 'Cherry' },
],
},
{
id: 'veg',
label: 'Vegetables',
children: [
{ id: 'carrot', label: 'Carrot' },
{ id: 'kale', label: 'Kale' },
],
},
];
export default function Example() {
return (
<div style={{ maxWidth: 280 }}>
<Tree
items={items}
defaultExpandedIds={['fruit', 'veg']}
selectionMode="multiple"
defaultSelectedIds={['apple', 'kale']}
aria-label="Groceries"
/>
</div>
);
}
Custom rendering
Section titled “Custom rendering”Pass a render function as the children prop to fully customise each row — receive the node plus depth, expanded, selected, focused, and disabled flags.
import { Tree, TreeItemLabel, type TreeNode } from '@arshad-shah/cynosure-react';
const IconFolder = () => (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z" />
</svg>
);
const IconFile = () => (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9Z" />
<path d="M14 3v6h6" />
</svg>
);
const items: TreeNode[] = [
{
id: 'src',
label: 'src',
children: [
{ id: 'src/App.tsx', label: 'App.tsx' },
{ id: 'src/main.tsx', label: 'main.tsx' },
],
},
];
export default function Example() {
return (
<div style={{ maxWidth: 280 }}>
<Tree items={items} defaultExpandedIds={['src']} aria-label="Project">
{({ item }) => {
const hasChildren = Array.isArray(item.children) && item.children.length > 0;
return (
<TreeItemLabel icon={hasChildren ? <IconFolder /> : <IconFile />}>
{item.label}
</TreeItemLabel>
);
}}
</Tree>
</div>
);
}
Data shape & adapters
Section titled “Data shape & adapters”Tree accepts an array of TreeNode-shaped items by default — { id, label?, children?, disabled?, icon? }. If your data already matches, drop it in directly.
const items: TreeNode[] = [ { id: 'a', label: 'A', children: [{ id: 'a/1', label: 'A1' }] },];
<Tree items={items} aria-label="Files" />Your data doesn’t match? Use accessor props
Section titled “Your data doesn’t match? Use accessor props”When the consumer’s data has different field names (uuid, name, subItems, …), point at them with getId / getLabel / getChildren / getDisabled instead of rebuilding the array.
interface ApiCollectionItem { uuid: string; name: string; items?: ApiCollectionItem[]; locked?: boolean;}
<Tree<ApiCollectionItem> items={apiData} getId={(n) => n.uuid} getLabel={(n) => n.name} getChildren={(n) => n.items} getDisabled={(n) => n.locked} aria-label="API collections"/>The render prop’s item argument is fully typed as ApiCollectionItem, so you can read your own fields directly without casts.
Prefer a one-shot transform? Use mapToTreeNodes
Section titled “Prefer a one-shot transform? Use mapToTreeNodes”If you’d rather normalise once at the data boundary and reuse the result elsewhere, the same accessors are accepted by the mapToTreeNodes helper:
import { Tree, mapToTreeNodes } from '@arshad-shah/cynosure-react';
const items = mapToTreeNodes(apiData, { getId: (n) => n.uuid, getLabel: (n) => n.name, getChildren: (n) => n.items, getDisabled: (n) => n.locked,});
<Tree items={items} aria-label="API collections" />Pick whichever fits the call site — both paths produce identical behaviour.
Accessibility
Section titled “Accessibility”- Implements the WAI-ARIA tree pattern: the root is
role="tree", each row isrole="treeitem", and nested branches arerole="group". - Every node exposes
aria-expanded(when it has children),aria-selected(when selection is enabled),aria-level, andaria-disabled. - Keyboard:
ArrowUp/ArrowDownmove focus;ArrowRightopens a branch or moves to the first child;ArrowLeftcloses a branch or moves to the parent;Home/Endjump to the first / last visible row;Enter/Spacetoggle and activate;*expands every sibling at the current level. - Roving
tabIndexkeeps the tree a single tab stop within the surrounding form / page. - Provide an
aria-label(oraria-labelledby) so the tree’s purpose is announced.
Recipes
Section titled “Recipes”- Use
defaultExpandedIdsfor uncontrolled expansion; pairexpandedIds+onExpandedChangeto control state externally. - Switch
selectionModebetween"single"and"multiple"for picker-style trees. - Pass a
childrenrender prop to layer in custom icons, badges, or trailing actions. - Use the exported
treeCollectIdshelper to implement an “expand all” toggle without recursion at the call site.