Skip to main content

Tree

Controlled expand/collapse and optional single selection. Keyboard-friendly rows.

Quick start
import { Tree } from "mates-ui";

Tree({
  nodes: [{ id: "a", label: "Root" }],
  expandedIds: [],
  onExpandChange: () => {},
});

Basic tree

import { html, atom, renderApp } from 'mates';
import { Tree, Text } from 'mates-ui';
import { DataObjectIcon, DescriptionIcon, FolderIcon, LockIcon, StyleIcon } from 'mates-icons';

const FILE_TREE = [
{ id: "root-src", label: "src", icon: FolderIcon(), children: [
{ id: "root-lib", label: "lib", icon: FolderIcon(), children: [
{ id: "f-tree", label: "tree", icon: FolderIcon(), children: [
{ id: "f-tree-ts", label: "tree.ts", icon: DescriptionIcon() },
{ id: "f-tree-css", label: "tree.css", icon: StyleIcon() },
],
},
{ id: "f-index", label: "index.ts", icon: DescriptionIcon() },
],
},
{ id: "root-demo", label: "demo", icon: FolderIcon(), children: [
{ id: "f-app", label: "app.ts", icon: DescriptionIcon() },
],
},
],
},
{ id: "pkg", label: "package.json", icon: DataObjectIcon() },
{ id: "disabled-node", label: "locked-config (disabled)", icon: LockIcon(), disabled: true },
];

const App = () => {
const expanded = atom(["root-src", "root-lib", "f-tree"]);
const selected = atom("f-tree-ts");
return () => html`
<x-col gap="var(--md-space-2)" style="max-width:28rem;width:100%;">
<div style="border:1px solid var(--md-color-border);border-radius:var(--md-radius-md);padding:var(--md-space-3);background:var(--md-color-surface);">
${Tree({ nodes: FILE_TREE, expandedIds: expanded(), onExpandChange: (ids) => expanded.set(ids), selectedId: selected(), onSelect: (id) => selected.set(id), ariaLabel: "Project files" })}
</div>
${Text("Selected: " + (selected() ?? "(none)"), { type: "body-sm", color: "muted" })}
</x-col>
`;

Node selection

import { html, atom, renderApp } from 'mates';
import { Tree, Text } from 'mates-ui';
import { ButtonsAltIcon, FolderIcon, HomeIcon, InfoIcon, InputIcon, MailIcon } from 'mates-icons';

const TREE = [
{ id: "pages", label: "Pages", icon: FolderIcon(), children: [
{ id: "home", label: "Home", icon: HomeIcon() },
{ id: "about", label: "About", icon: InfoIcon() },
{ id: "contact", label: "Contact", icon: MailIcon() },
],
},
{ id: "components", label: "Components", icon: FolderIcon(), children: [
{ id: "btn", label: "Button", icon: ButtonsAltIcon() },
{ id: "input", label: "Input", icon: InputIcon() },
],
},
];

const App = () => {
const expanded = atom(["pages"]);
const selected = atom(null);
return () => html`
<x-col gap="var(--md-space-2)" style="max-width:28rem;width:100%;">
<div style="border:1px solid var(--md-color-border);border-radius:var(--md-radius-md);padding:var(--md-space-3);background:var(--md-color-surface);">
${Tree({ nodes: TREE, expandedIds: expanded(), onExpandChange: (ids) => expanded.set(ids), selectedId: selected(), onSelect: (id) => selected.set(id), ariaLabel: "Site navigation" })}
</div>
${Text(selected() ? "Selected: " + selected() : "Click a leaf node to select it.", { type: "body-sm", color: "muted" })}
</x-col>
`;
};

renderApp(App, document.getElementById('app'));

Custom icons

import { html, atom, renderApp } from 'mates';
import { Tree } from 'mates-ui';
import { ArticleIcon, FontDownloadIcon, ImageIcon, InterestsIcon, Inventory2Icon, SettingsIcon } from 'mates-icons';

const ICON_TREE = [
{ id: "assets", label: "assets", icon: Inventory2Icon(), children: [
{ id: "img", label: "images", icon: ImageIcon() },
{ id: "fonts", label: "fonts", icon: FontDownloadIcon() },
{ id: "icons", label: "icons", icon: InterestsIcon() },
],
},
{ id: "config", label: "vite.config.ts", icon: SettingsIcon() },
{ id: "readme", label: "README.md", icon: ArticleIcon() },
];

const App = () => {
const expanded = atom(["assets"]);
return () => html`
<div style="max-width:28rem;width:100%;border:1px solid var(--md-color-border);border-radius:var(--md-radius-md);padding:var(--md-space-3);background:var(--md-color-surface);">
${Tree({ nodes: ICON_TREE, expandedIds: expanded(), onExpandChange: (ids) => expanded.set(ids), ariaLabel: "Asset tree" })}
</div>
`;
};

renderApp(App, document.getElementById('app'));

Disabled nodes

import { html, atom, renderApp } from 'mates';
import { Tree, Text } from 'mates-ui';
import { DescriptionIcon, FolderIcon, ImageIcon, LockIcon } from 'mates-icons';

const DISABLED_TREE = [
{ id: "public", label: "public", icon: FolderIcon(), children: [
{ id: "favicon", label: "favicon.ico", icon: ImageIcon() },
{ id: "robots", label: "robots.txt", icon: DescriptionIcon(), disabled: true },
],
},
{ id: "src-d", label: "src", icon: FolderIcon(), disabled: true, children: [
{ id: "main-d", label: "main.ts", icon: DescriptionIcon() },
],
},
{ id: "env", label: ".env (disabled)", icon: LockIcon(), disabled: true },
];

const App = () => {
const expanded = atom([]);
return () => html`
<x-col gap="var(--md-space-2)" style="max-width:28rem;width:100%;">
<div style="border:1px solid var(--md-color-border);border-radius:var(--md-radius-md);padding:var(--md-space-3);background:var(--md-color-surface);">
${Tree({ nodes: DISABLED_TREE, expandedIds: expanded(), onExpandChange: (ids) => expanded.set(ids), ariaLabel: "Disabled nodes demo" })}
</div>
${Text("Disabled nodes cannot be expanded or selected.", { type: "body-sm", color: "muted" })}
</x-col>
`;
};

renderApp(App, document.getElementById('app'));

Tree

import { Tree } from "mates-ui";
Prop Type Default Description
ariaLabel string Accessible label applied to the root <ul> element.
attr AttrMap Extra HTML attributes forwarded to internal elements.
classes string | string[] Extra root classes — joined and appended after built-in classes.
data DataAttrMap Root `data-*` map (`testId` → `data-testid`). `testId` prop / `attr` win over `data`.
dense boolean false Reduces row padding for a more compact tree.
expandedIds* string[] Controlled list of currently expanded node IDs.
id string Root `id` shorthand (`attr.id` wins). Form native-control `id` props are unchanged.
indentStep number 16 Pixel indent added per nesting level.
nodes* TreeNode[] Array of root-level tree nodes to render.
on OnEventMap DOM event handlers on the root element.
onExpandChange* (ids: string[]) => void Called with the full new set of expanded IDs whenever a node is toggled.
onSelect (id: string, node: TreeNode<T>) => void Called with the node ID and node data when a selectable node is clicked.
selectedId string | null ID of the currently selected node.
style StyleMap Inline CSS on the root element.
style StyleMap Root inline CSS (`style` prop; `style` wins if both set).
testId string Overrides the default data-testid attribute.

TreeNode

Prop Type Default Description
children TreeNode[] Nested child nodes — presence makes the row expandable.
data T Custom payload attached to the node.
disabled boolean When true the node cannot be selected or expanded.
icon string Material Symbol name shown before the label.
id* string Unique identifier for the node.
label* string Display text rendered inside the row.

CSS tokens

Token Role
--md-color-primary Text and icon colour of the selected row.
--md-color-primary-muted Background of the selected row.
--md-color-surface-raised Background of a hovered row.

Caveats

  • Tree is fully controlled — always update `expandedIds` in `onExpandChange`.
  • Deeply nested trees may affect performance — consider virtualizing at 500+ nodes.
  • Disabled nodes cannot be selected or expanded.