📘 Documentation & live demo · Getting started · Guides · API reference · Migration from v1
A simple, data-driven, zero-runtime-dependency React tree menu with:
- Full keyboard navigation (WAI-ARIA tree pattern)
- Built-in search with debouncing
- Works with any styling stack — import our CSS, skip it and use Tailwind utilities via a
classNamesprop, or swap in render-props for full control - React 16.14+ (hooks), 17, 18, 19 — all tested in CI
- < 3 KB gzipped
npm install react-simple-tree-menuimport TreeMenu from 'react-simple-tree-menu';
import 'react-simple-tree-menu/styles'; // optional, see "Styling" belowimport TreeMenu from 'react-simple-tree-menu';
import 'react-simple-tree-menu/styles';
const data = [
{
key: 'fruits',
label: 'Fruits',
nodes: [
{ key: 'apple', label: 'Apple' },
{ key: 'banana', label: 'Banana' },
],
},
{ key: 'vegetables', label: 'Vegetables' },
];
export function Example() {
return (
<TreeMenu
data={data}
onClickItem={({ key, label }) => console.log('clicked', key, label)}
/>
);
}Two equivalent shapes — pick whichever fits your source data:
Array of nodes (order matters, unique key per sibling):
const data = [
{ key: 'a', label: 'A', nodes: [{ key: 'a1', label: 'A1' }] },
{ key: 'b', label: 'B' },
];Object of nodes (sorted by numeric index):
const data = {
a: { label: 'A', index: 0, nodes: { a1: { label: 'A1', index: 0 } } },
b: { label: 'B', index: 1 },
};Arbitrary custom fields (e.g. url, icon) flow through each node and show up on the Item passed to onClickItem.
Focus the tree (Tab), then:
| Key | Action |
|---|---|
| ↑ / ↓ | Move focus to previous / next visible item |
| ← | Close the focused branch (or move focus to its parent) |
| → | Open a closed branch · on an open branch, focus its first child |
| Enter | Activate focused item and fire onClickItem |
Three paths — pick the one that matches your stack:
import 'react-simple-tree-menu/styles';Ships a compact CSS file (~2.5 KB) with sans-serif fonts, subtle hover, inset focus ring, and indigo active state. Every color, radius, and spacing value is a CSS custom property — defaults are on :root with zero specificity, so you override globally, per-theme, or per-panel and the change inherits into every tree inside:
/* global */
:root {
--rstm-active-bg: #f97316; /* orange */
--rstm-radius: 0; /* square corners */
--rstm-transition: 120ms ease-out; /* opt into motion */
}
/* per-theme (light / dark attribute) */
:root[data-theme='dark'] {
--rstm-text-color: #f3f4f6;
--rstm-hover-bg: #1f2937;
}
/* per-panel (one tree themed differently from the rest) */
.my-dark-panel {
--rstm-active-bg: #60a5fa;
}The complete list of --rstm-* tokens and their default values lives in src/styles.css. A docs-site guide with live previews is on the roadmap.
Tailwind v4 auto-exposes theme colors as CSS variables (--color-primary, --color-gray-300, …). The library's default stylesheet reads them via var() chains, so:
import 'react-simple-tree-menu/styles'; // that's itActive state tracks your --color-primary, borders track your --color-gray-300, body font tracks your --font-sans. Fallback palette kicks in where your theme is silent.
Pass utility classes via the classNames prop:
<TreeMenu
data={data}
classNames={{
item: 'py-3 px-4 cursor-pointer',
active: 'bg-indigo-600 text-white',
focused: 'ring-2 ring-offset-2 ring-indigo-500',
search: 'py-2 px-3 w-full border rounded',
}}
/>The library's rstm-* anchor classes stay in the DOM (inert strings when the CSS isn't imported), so backward-compat CSS overrides keep working.
| Prop | Type | Default | Description |
|---|---|---|---|
data |
TreeNode[] | { [key]: TreeNode } |
— | The tree, in either format above. |
activeKey |
string |
— | Controlled selected-item key (full path). |
focusKey |
string |
— | Controlled keyboard-focused key. |
openNodes |
string[] |
— | Controlled set of expanded branch keys. |
initialActiveKey |
string |
'' |
Uncontrolled initial selection. |
initialFocusKey |
string |
'' |
Uncontrolled initial focus. |
initialOpenNodes |
string[] |
[] |
Uncontrolled initial open branches. |
resetOpenNodesOnDataUpdate |
boolean |
false |
Reset openNodes to initial when data ref changes. |
hasSearch |
boolean |
true |
Render the default search input. |
onClickItem |
(item) => void |
no-op | Called on click and on Enter. Receives the full Item. |
debounceTime |
number |
125 |
Search-input debounce in ms. |
locale |
(props) => string |
identity | Transform labels. Pass a stable ref. |
matchSearch |
(props) => boolean |
case-insensitive substring | Custom matcher. Pass a stable ref. |
disableKeyboard |
boolean |
false |
Skip the keyboard wrapper. |
children |
render-prop | default UI | Custom renderer — see below. |
classNames |
TreeMenuClassNames |
— | Per-slot class names appended to rstm-* anchors. |
labels |
TreeMenuLabels |
English defaults | i18n overrides for default-UI copy. |
keySeparator |
string |
'/' |
Delimiter joining node keys into paths. Change if your keys contain / (URLs, paths). |
const treeRef = useRef<TreeMenuHandle>(null);
<TreeMenu ref={treeRef} data={data} />;
treeRef.current?.resetOpenNodes(['fruits'], 'fruits/apple');
treeRef.current?.expandAll(); // every branch opens
treeRef.current?.collapseAll(); // back to roots onlyexpandAll / collapseAll preserve the user's current active / focus / search state. Both are no-ops when openNodes is controlled — the parent owns that slot. For controlled consumers, use the exported helper instead:
import TreeMenu, { collectBranchKeys } from 'react-simple-tree-menu';
const [open, setOpen] = useState<string[]>([]);
<button onClick={() => setOpen(collectBranchKeys(data))}>Expand all</button>
<button onClick={() => setOpen([])}>Collapse all</button>
<TreeMenu data={data} openNodes={open} />Perf note: collectBranchKeys is O(N) — microseconds even on 100k-node trees. The cost after expand-all comes from rendering every branch's children. For trees that grow beyond ~2k visible rows, pair with the virtualization recipe.
The items array is always flat — one entry per visible node in
depth-first order, with a level field for depth. How you render it is
up to you:
Flat: one <ul>, paddingLeft: level * N for indent. Simplest.
<TreeMenu data={data}>
{({ search, items, resetOpenNodes }) => (
<div>
<input onChange={(e) => search?.(e.target.value)} />
<ul>
{items.map(({ key, label, level, active, onClick }) => (
<li
key={key}
aria-level={level + 1}
aria-selected={!!active}
style={{ paddingLeft: 8 + level * 16 }}
className={active ? 'bg-indigo-500 text-white' : ''}
onClick={onClick}
>
{label}
</li>
))}
</ul>
</div>
)}
</TreeMenu>Nested <ul>/<li>/<ul>: reconstruct hierarchy from each item's
slash-joined key. Matches the WAI-ARIA tree pattern's preferred DOM
shape (children inside role="group"). The library exports unflatten
— the same helper defaultChildren uses internally — so you don't have
to reimplement the grouping:
import TreeMenu, { unflatten } from 'react-simple-tree-menu';
<TreeMenu data={data}>
{({ items }) => {
const { roots, childrenByParent } = unflatten(items);
const renderNode = (it) => (
<li key={it.key} role="treeitem" aria-level={it.level + 1}>
<div onClick={it.onClick}>{it.label}</div>
{childrenByParent.get(it.key) && (
<ul role="group">
{childrenByParent.get(it.key)!.map(renderNode)}
</ul>
)}
</li>
);
return <ul role="tree">{roots.map(renderNode)}</ul>;
}}
</TreeMenu>See the render-props guide for the full recipe and a11y notes, or the Storybook TreeMenu / Render-props stories for runnable versions of both.
If you don't need custom rendering, the default defaultChildren already
emits the canonical nested DOM with full ARIA attributes and keyboard
support — prefer it unless you need to swap the outer layout.
- React peer floor: raised from
>=16.6.3to>=16.14. Anyone on React 16.14+, 17, 18, 19 is already good. - Removed prop:
cacheSearch— internal memoization now keyed onuseMemodependencies; no user-facing knob. Delete the prop from any call site. - CSS import path: use
react-simple-tree-menu/styles. The v1react-simple-tree-menu/dist/main.cssdeep-import no longer resolves — swap it for the subpath export (same byte content). - Default search copy:
"Type and search"→"Search". Passlabels={{ searchPlaceholder: 'Type and search' }}to restore. - Toggle glyph:
"+" / "-"→"▸" / "▾". PassopenedIcon/closedIcontoItemComponent(via custom render-props) to restore. - Default active color: saturated blue → indigo. Override
--rstm-active-bgfor the old look.
Class-component v1 ref patterns keep working via the new TreeMenuHandle — resetOpenNodes is still callable through a ref.
All types are published alongside the JS. Exported:
// Types
(TreeMenuProps,
TreeMenuHandle,
TreeMenuItem,
TreeMenuChildren,
TreeMenuClassNames,
TreeMenuLabels,
TreeNode,
TreeNodeObject,
TreeNodeInArray,
LocaleFunction,
MatchSearchFunction,
Item,
UnflattenResult);
// Values
(ItemComponent, defaultChildren, KeyDown, unflatten);MIT — see LICENSE.