Headless, virtualized checkbox tree for React 18+.
- Virtualized rows with stable item keys
- Checkbox semantics with tri-state support (checked / unchecked / indeterminate)
- Search overlay that auto-expands matches and limits toggles to visible leaves
- Controlled or uncontrolled checked, expanded, and search states
- Pluggable renderers for expander, checkbox, and row content
- Imperative helpers to expand, collapse, or scroll to an item
- Lightweight: only depends on
@tanstack/react-virtual
npm install react-virtual-checkbox-tree
# or
yarn add react-virtual-checkbox-tree
# or
pnpm add react-virtual-checkbox-treePeer deps: react@>=18, react-dom@>=18.
Minimal uncontrolled render:
import { Tree, type TreeDefinition } from "react-virtual-checkbox-tree";
const data: TreeDefinition = {
__root__: { id: "__root__", label: "root", children: ["a", "b"] },
a: { id: "a", label: "Alpha" },
b: { id: "b", label: "Beta" },
};
export default function App() {
return <Tree data={data} height={240} />;
}Controlled with search:
import { Tree, type TreeDefinition } from "react-virtual-checkbox-tree";
import { useMemo, useRef, useState } from "react";
const data = useMemo<TreeDefinition>(
() => ({
__root__: { id: "__root__", label: "root", children: ["docs", "src"] },
docs: { id: "docs", label: "Docs", children: ["readme"] },
readme: { id: "readme", label: "README.md" },
src: { id: "src", label: "src", children: ["engine", "tree"] },
engine: { id: "engine", label: "engine.ts" },
tree: { id: "tree", label: "tree.tsx" },
}),
[]
);
export function FileTree() {
const [checked, setChecked] = useState<string[]>([]);
const [expanded, setExpanded] = useState<string[]>(["__root__", "docs"]);
const [query, setQuery] = useState("");
return (
<div style={{ height: 320, border: "1px solid #e0e0e0" }}>
<input
placeholder="Search"
value={query}
onChange={(e) => setQuery(e.target.value)}
style={{ width: "100%", boxSizing: "border-box", marginBottom: 8 }}
/>
<Tree
data={data}
checkedItems={checked}
expandedItems={expanded}
searchQuery={query}
onCheck={setChecked}
onExpand={setExpanded}
height={280}
/>
</div>
);
}Render customization example:
<Tree
data={data}
renderExpander={({ isExpanded, onToggle }) => (
<button
onClick={(e) => {
e.stopPropagation();
onToggle();
}}
>
{isExpanded ? "β¬οΈ" : "β‘οΈ"}
</button>
)}
renderCheckbox={({ checkedState, onChange }) => (
<MyCheckbox
checked={checkedState === "checked"}
indeterminate={checkedState === "indeterminate"}
onChange={onChange}
/>
)}
renderItem={(item) => <span style={{ display: "inline-flex", gap: 6 }}>{item.label}</span>}
height={320}
/>| Field | Type | Required | Description |
|---|---|---|---|
id |
string | number |
yes | Stable identifier; should match the map key. |
label |
string |
yes | Text shown by default renderer. |
children |
string[] |
no | Child IDs; omit or empty array for leaves. |
data |
Record<string, any> |
no | Extra metadata passed through to renderers. |
TreeDefinition = Record<string, TreeItem>; the key is the node ID. You must include a root entry (default expected ID is "__root__") whose children array represents the top-level nodes.
| Prop | Type | Default | Description |
|---|---|---|---|
data |
TreeDefinition |
Required tree map including the __root__ node. |
|
height |
number | string |
"100%" |
Height of the scroll container. |
className |
string |
Extra class for the scroll container. | |
checkedItems |
string[] | null |
Controlled list of checked leaf IDs; pair with onCheck. |
|
expandedItems |
string[] | null |
Controlled list of expanded folder IDs; pair with onExpand. |
|
searchQuery |
string |
"" |
Controlled search string; activates search when length β₯ minSearchChars. |
minSearchChars |
number |
3 |
Minimum characters before search activates. |
onCheck |
(ids: string[]) => void |
Fired when checked leaf IDs change. | |
onExpand |
(ids: string[]) => void |
Fired when expanded folder IDs change. | |
renderItem |
(item: TreeItem) => ReactNode |
item.label |
Custom row body (label/icon/etc). |
renderCheckbox |
(props: TreeCheckboxRenderProps) => ReactNode |
Custom checkbox renderer (supports indeterminate). | |
renderExpander |
(props: TreeExpanderRenderProps) => ReactNode |
Custom expander renderer, called only for folders. |
The forwarded ref exposes:
expandAll()β expand every folder.collapseAll()β collapse all folders except the root.scrollToId(id)β virtualized scroll to an item by ID.
- Queries are normalized (lowercased, diacritics stripped).
- When search is active, only matching leaves and their ancestors are visible; folders containing matches are auto-expanded.
- Toggling a folder or leaf in search mode only affects visible leaves. Hidden leaves keep their previous state.
- Only leaves are checkable; folder check state is derived from children (checked / indeterminate / unchecked).
- In normal mode, toggling a node cascades through its full subtree. In search mode, toggling only affects currently visible leaves.
onCheckalways returns the full list of checked leaf IDs (not folders).
- Use stable string IDs; they are keys for virtualization, caching, and selection.
- Memoize
datato avoid rebuilding the engine on every render. - Keep
heightfixed (number or px) when possible for consistent virtualization. - In controlled mode, update
checkedItemsandexpandedItemsin response toonCheck/onExpand. - For larger row heights, fork
tree.tsxor use theEnginedirectly to provide a customestimateSize.
You can build a fully custom UI using the exported Engine, CheckedState, and types. The engine handles structure, search, and tri-state logic.
import { Engine, CheckedState } from "react-virtual-checkbox-tree";
const engine = new Engine(data, { minSearchChars: 2 });
engine.subscribe(() => {
const visible = engine.getVisibleItems();
// render your own list; call engine.toggle / toggleExpanded as needed
});
engine.setSearchQuery("foo");
engine.toggle("some-id", true);Engine highlights:
getVisibleItems()returns the flattened, view-aware rows with level and expansion state.toggle(id, checked)cascades differently in normal vs search mode (full subtree vs visible leaves only).toggleExpanded(id)flips a folder;expandAll/collapseAllavailable on the class.setChecked(ids)lets you control the selection externally.
- Renders only the visible rows via
@tanstack/react-virtual; large trees stay smooth because DOM size stays small. - Selection and expansion are cached; state recomputation is localized to touched branches.
- Search normalizes labels once (diacritics stripped, lowercased) and activates only after
minSearchChars, avoiding noisy short queries. scrollToIduses the virtualizer index map to jump directly to an item.
- Use stable string IDs; they are used as keys and lookups throughout the engine.
- Provide the root node in
data; itschildrenare the visible top-level items. - The virtualizer estimates row height at 32px; if your rows are taller, pass a custom
estimateSizeby forkingtree.tsxor use theEnginedirectly.
Copyright Β© 2025 Jad Ghadry. Released under the MIT license.