An accessible, customizable tree select and checkbox tree component for React, with tri-state (indeterminate) selection, keyboard navigation, TypeScript generics, and replaceable parts.
Documentation · Examples · Storybook playground · API reference
- Tri-state selection. Checking a parent checks its descendants, and parents show checked, unchecked or indeterminate based on what is selected beneath them. Disabled nodes are respected. Turn it off for independent selection.
- Accessible. Follows the WAI-ARIA tree view pattern: a single tab stop, arrow key navigation, and screen reader support for expanded, checked and mixed states. Tested with axe.
- Works with your data. Pass your own tree-shaped objects and name the properties that hold the ID, label, children and checked state. TypeScript checks the property names against your node type.
- Customizable. Style every part with class names, which always override the defaults, or replace the node, label, checkbox or expand icon with your own components, for example to build a file tree or a comment thread.
- Controlled or uncontrolled, with helpers to read the selected IDs.
- Fast with large trees. Changes copy only the affected branch and unchanged nodes skip re-rendering.
- Small. About 17 KB of JavaScript and 3 KB of CSS, with no global style resets. Supports React 18 and 19 and server-side rendering.
npm install @attebyte/react-tree-selectyarn add @attebyte/react-tree-selectpnpm add @attebyte/react-tree-selectimport TreeSelect from '@attebyte/react-tree-select';
import '@attebyte/react-tree-select/style.css';
interface Category {
label: string;
value: number;
included?: boolean | null; // true = checked, null = partially checked
children?: Category[];
}
const categories: Category[] = [
{
label: 'Root 1',
value: 1,
children: [
{
label: 'Child 1',
value: 2,
children: [
{ label: 'Grandchild 1', value: 3 },
{ label: 'Grandchild 2', value: 4 }
]
},
{ label: 'Child 2', value: 5 }
]
},
{
label: 'Root 2',
value: 6,
children: [
{ label: 'Child 3', value: 7 },
{ label: 'Child 4', value: 8 }
]
}
];
export default function App() {
return (
<TreeSelect
defaultData={categories}
uniqueIDProperty='value'
labelProperty='label'
childrenProperty='children'
valueProperty='included'
onChange={tree => console.log(tree)}
aria-label='Categories'
/>
);
}onChange receives the updated tree. To get the selected IDs, use getSelectedIDs:
import { getSelectedIDs } from '@attebyte/react-tree-select';
const ids = getSelectedIDs(tree, {
uniqueIDProperty: 'value',
childrenProperty: 'children',
valueProperty: 'included',
mode: 'leaves' // or 'all' (default), or 'topmost'
});'leaves' returns only checked nodes without children, and 'topmost' lets a fully checked parent stand in for its subtree. getSelectedNodes returns the nodes instead of their IDs. See Selection.
Pass data instead of defaultData to keep the tree in your own state. The tree renders exactly the data it is given, so update it from onChange:
const [data, setData] = useState(categories);
<TreeSelect
data={data}
onChange={setData}
uniqueIDProperty='value'
labelProperty='label'
childrenProperty='children'
valueProperty='included'
/>Your data is never modified. onChange receives a new tree in which unchanged nodes are reused from the previous one, so treat it as immutable. See Controlled usage.
The most commonly used props. See the API reference for all of them.
| Prop | Type | Description |
|---|---|---|
data / defaultData |
`T[] | T` |
uniqueIDProperty |
keyof T |
Required. Property that uniquely identifies each node. |
labelProperty |
keyof T |
Required. Property holding the node's label. |
childrenProperty |
keyof T |
Required. Property holding the node's children. |
valueProperty |
keyof T |
Property holding the checked state. Without it, the tree is display-only. |
disabledProperty |
keyof T |
Property that marks a node as disabled. |
onChange |
(data: T[]) => void |
Called with the updated tree whenever a selection changes. |
chainedSelect |
boolean |
Default true. Selection flows to descendants and parents reflect their children. |
defaultExpanded |
boolean |
Default false. Whether nodes start expanded. |
The default styles only apply to the tree's own elements: every class is prefixed with rts:, and the rules live in the components cascade layer. Your own classes and CSS therefore always override them, whether you use Tailwind 4, Tailwind 3 or plain CSS. One consequence: unlayered global resets in your app (such as * { padding: 0 }) also override them, so scope those resets or put them in a lower layer.
TreeSelect is made of four replaceable parts:
TreeNode: the container for a node and its nested nodesNodeLabel: the label displayed in each nodeNodeCheck: the checkbox that selects a node or shows its descendants' selectionExpandIcon: the icon that shows whether a node with children is expanded or collapsed
Style them with nodeClassName, labelClassName, inputClassName and iconClassName, or replace them with CustomNodeComponent, CustomLabelComponent, CustomCheckComponent and CustomExpandIconComponent. A custom TreeNode should spread treeItemProps onto its root element and render children (the nested nodes) so keyboard navigation keeps working:
const MyNode = (props: TreeNodeProps<Category>) => {
const { standardComponentProps, treeItemProps, expanded, onExpandedChange, children } = props;
return (
<div {...treeItemProps}>
<button onClick={() => onExpandedChange?.(!expanded)}>{standardComponentProps.node.label}</button>
{children && <div role='group'>{children}</div>}
</div>
);
};See Customization and the examples.
Give each tree an accessible name with aria-label or aria-labelledby. The tree is a single tab stop. Once it has focus:
| Key | Action |
|---|---|
| ↓ / ↑ | Move to the next or previous visible node |
| → | Expand a collapsed node, or move to the first child of an expanded node |
| ← | Collapse an expanded node, or move to the parent node |
| Home / End | Move to the first or last visible node |
| Space | Check or uncheck the node |
| Enter | Activate the node, the same as clicking its label |
See Accessibility.
Version 2.0 contains breaking changes:
- Controlled mode is now fully controlled. When you pass
data, the tree renders exactly that data, so selections only appear once you pass the tree fromonChangeback in asdata. Previously the tree kept its own copy and updated it even if you ignoredonChange. If you don't want to manage the state yourself, usedefaultData. - Parents with disabled children are mixed, not checked. With chained selection, checking a parent whose disabled child is unchecked now makes the parent mixed (
null) instead of checked, so its value always reflects its subtree. Clicking it again unchecks its enabled descendants. A parent whose descendants are all disabled is now shown as disabled, since it can never change (in 1.x it could be checked on its own). onChangereceives a structurally shared tree, and is only called when something changed. Changed nodes and their ancestors are new objects, and every other node is the same object as before. Previously every change produced a full deep copy. Your input data is still never modified.- Nodes no longer contain a
_parentIDproperty. Custom components that readnode._parentIDmust track parents themselves. - Expansion state is managed by
TreeSelect.TreeNodenow receivesexpanded,onExpandedChange,treeItemProps, and its nested nodes aschildren, instead of managing its own state and rendering its children itself. Custom node components that manage their own expansion and render their own children still work, but they aren't part of keyboard navigation unless they spreadtreeItemPropsand renderchildren. If such a component spreads its remaining props onto a DOM element, destructuretreeItemProps,expanded,onExpandedChangeandchildrenfirst. - Changing
defaultExpandedafter the first render now expands or collapses every node. Previously it only affected nodes rendered afterward. - New DOM structure and ARIA roles. The root element has
role="tree", each node hasrole="treeitem", and nested nodes are wrapped in arole="group"element. The label no longer hasrole="button", the checkbox is removed from the tab order (the tree item carries its checked state), and the expand icon is hidden from assistive technology. Update any CSS selectors that relied on child nodes being direct children of their parent node. - The stylesheet no longer resets global styles. Version 1.x shipped Tailwind's Preflight reset, which changed the styles of the entire page. If your app relied on that reset, include one yourself (for example, Tailwind's
@import "tailwindcss"). - Styles are built with Tailwind CSS 4. The default styles need Safari 16.4+, Chrome 111+ or Firefox 128+.
- Package exports are restricted. The package now defines
exports, so only the main entry point and the stylesheet can be imported. Import styles from@attebyte/react-tree-select/style.css. The old@attebyte/react-tree-select/dist/style.csspath still works. - The license changed from GPL-3.0 to MIT.
dataanddefaultDataaccept a single root node as well as an array.onChangereports the tree in the same shape you passed in, so a controlled single-root tree works withonChange={setRoot}.- The stylesheet no longer affects elements outside the tree. In 2.0.0 its utility classes (such as
basis-full) were unlayered and unprefixed, so they matched and overrode identically named classes in the consuming app, including Tailwind 4 responsive variants. Classes are now prefixed withrts:and live in thecomponentscascade layer. If you worked around this by importing the stylesheet withlayer(components), you can keep or remove that.
- Keyboard navigation and screen reader support following the WAI-ARIA tree view pattern.
- New
getSelectedIDsandgetSelectedNodeshelpers. - Each selection change now updates only the affected nodes, and unchanged nodes skip re-rendering. Previously, every change cloned the entire tree and re-rendered every node.
- Expanding or collapsing a parent keeps the expansion state of its descendants.
- Nodes may contain values that cannot be cloned, such as functions or class instances. Previously, these caused an error.
- Several
onChangecalls from a custom component in one event (for example, "select all children") now combine instead of overwriting each other. - Controlled data renders on the first render and during server-side rendering, instead of after an effect.
- The checkbox sets its
indeterminatestate for partially selected nodes and scales with the surrounding font size. NodeLabelno longer passeslabelanddisabledthrough to the DOM.- A warning is logged when several nodes share the same ID.
- A custom component can call
onChange(node, value)as a plain setter, or pass{ toggle: true }to get the same behavior as a click. - Labels that are not text (such as React elements) are no longer turned into "[object Object]" as the accessible name.
- The bundle no longer includes
clsx,class-variance-authority, andtailwind-merge, which are installed as dependencies. This reducesmain.jsfrom 69 KB to 17 KB. - The stylesheet only contains the component's own styles (3 KB, down from 14 KB).
- Type declarations are bundled into a single file, so they also work with
"moduleResolution": "node16"or"nodenext", and the package can be loaded withrequire()in Node.js and Jest.
npm install
npm run storybook # Storybook at http://localhost:6006
npm test # unit tests (watch mode)
npm run test:storybook # stories with interaction and accessibility checks in a real browser
npm run lint
npm run build # library to dist/
npm run build-site # library, Storybook and the documentation siteThe documentation site lives in website/ (Docusaurus), and the examples it shares with Storybook live in examples/.
MIT © Attebyte LLC
This is my first open-source project, and feedback is always appreciated. Twitter
