@mshafiqyajid/react-tree
Headless tree view + styled component. Expand/collapse, single or multi-select, async children loader, full keyboard navigation (β β β β Home End Enter), indent guide lines, ARIA tree pattern. Zero dependencies, fully typed.
Playground #
- src
- components
- Button.tsx
- Card.tsx
- Modal.tsx
- hooks
- index.ts
- App.tsx
- public
- package.json
- README.md
selected: (none)
Props
TSX
import { TreeStyled } from "@mshafiqyajid/react-tree/styled";
import "@mshafiqyajid/react-tree/styles.css";
<TreeStyled
items={items}
searchQuery={search}
defaultExpandedIds={["src", "components"]}
/>Install #
npm install @mshafiqyajid/react-tree Quick start #
import { TreeStyled } from "@mshafiqyajid/react-tree/styled";
import "@mshafiqyajid/react-tree/styles.css";
const items = [
{ id: "src", label: "src", children: [
{ id: "components", label: "components", children: [
{ id: "Button.tsx", label: "Button.tsx" },
]},
{ id: "index.ts", label: "index.ts" },
]},
{ id: "package.json", label: "package.json" },
];
<TreeStyled
items={items}
defaultExpandedIds={["src"]}
onSelectedChange={(id) => console.log(id)}
/> Async children #
<TreeStyled
items={[{ id: "root", label: "root", children: undefined }]}
loadChildren={async (node) => {
const res = await fetch("/api/children/" + node.id);
return res.json();
}}
/> When a node has children: undefined AND a loadChildren is set, expanding it triggers the loader. The chevron is replaced with a spinner during the request.
Multi-select #
<TreeStyled
items={items}
selectionMode="multiple"
onSelectedIdsChange={(ids) => setSelection(ids)}
/> Keyboard navigation #
| Key | Action |
|---|---|
| β / β | Move focus between visible nodes |
| β | Expand collapsed parent / move to first child |
| β | Collapse expanded parent / move to parent |
| Home / End | Jump to first / last visible node |
| Enter / Space | Select the focused node |
Headless #
import { useTree } from "@mshafiqyajid/react-tree";
const tree = useTree({ items, defaultExpandedIds: ["src"] });
return (
<ul {...tree.getRootProps()}>
{tree.visibleNodes.map(({ node, depth, hasChildren }) => (
<li key={node.id} {...tree.getNodeProps(node, depth)} style={{ paddingLeft: depth * 18 }}>
{hasChildren && (
<button {...tree.getToggleProps(node)}>
{tree.isExpanded(node.id) ? "βΎ" : "βΈ"}
</button>
)}
{node.label}
</li>
))}
</ul>
); API #
| Prop | Type | Default | Description |
|---|---|---|---|
| items | TreeNode[] | β | Required |
| defaultExpandedIds / expandedIds / onExpandedChange | string[] / (ids) => void | [] | Expanded ids (uncontrolled or controlled) |
| defaultSelectedId / selectedId / onSelectedChange | string | null / (id, node) => void | null | Single-selected id |
| selectionMode | "single" | "multiple" | "single" | β |
| selectedIds / onSelectedIdsChange | string[] / (ids) => void | β | Multi-select state (when selectionMode="multiple") |
| loadChildren | (node) => Promise<TreeNode[]> | β | Async children loader |
| size | "sm" | "md" | "lg" | "md" | Row size |
| tone | "neutral" | "primary" | "primary" | Accent color |
| showGuides | boolean | true | Vertical indent guide lines |
| searchQuery | string | "" | Filter visible nodes by case-insensitive label match. Auto-expands matching ancestors. |
| highlightMatches | boolean | true | Bolden matched query characters in labels |
| checkboxes | boolean | false | Render a checkbox per node alongside the label |
| renderLabel | (node, depth) => ReactNode | β | Custom label renderer |
| renderBadge | (node) => ReactNode | β | Slot rendered after the label (count, status, etc.) |
| emptyState | ReactNode | auto | Shown when no nodes match the search |
TreeNode #
| Field | Type | Description |
|---|---|---|
| id | string | Required, unique |
| label | ReactNode | Required |
| children | TreeNode[] | undefined | undefined = leaf or async-loadable. [] = empty branch. |
| icon | ReactNode? | Left-side icon |
| disabled | boolean? | Greyed, can't toggle/select |
| data | T? | Free-form payload |