react ~5 KB 0 deps v0.3.0 β†— GitHub β†—

@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
  • index.ts
  • App.tsx
  • 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 #

KeyAction
↓ / ↑Move focus between visible nodes
β†’Expand collapsed parent / move to first child
←Collapse expanded parent / move to parent
Home / EndJump to first / last visible node
Enter / SpaceSelect 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 #

PropTypeDefaultDescription
itemsTreeNode[]β€”Required
defaultExpandedIds / expandedIds / onExpandedChangestring[] / (ids) => void[]Expanded ids (uncontrolled or controlled)
defaultSelectedId / selectedId / onSelectedChangestring | null / (id, node) => voidnullSingle-selected id
selectionMode"single" | "multiple""single"β€”
selectedIds / onSelectedIdsChangestring[] / (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
showGuidesbooleantrueVertical indent guide lines
searchQuerystring""Filter visible nodes by case-insensitive label match. Auto-expands matching ancestors.
highlightMatchesbooleantrueBolden matched query characters in labels
checkboxesbooleanfalseRender 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.)
emptyStateReactNodeautoShown when no nodes match the search

TreeNode #

FieldTypeDescription
idstringRequired, unique
labelReactNodeRequired
childrenTreeNode[] | undefinedundefined = leaf or async-loadable. [] = empty branch.
iconReactNode?Left-side icon
disabledboolean?Greyed, can't toggle/select
dataT?Free-form payload
Edit this page on GitHub