Drag-and-drop sortable representation of hierarchical data for React 18/19 with virtualized rendering powered by virtua and react-dnd. Storybook demos cover both basic and advanced scenarios.
Install the package together with its peer dependencies:
npm install @nosferatu500/react-sortable-tree react-dnd react-dnd-html5-backend
# or
yarn add @nosferatu500/react-sortable-tree react-dnd react-dnd-html5-backendThe bundle is ESM-only and includes all styles via runtime injection (no separate CSS file is required).
import{useState}from'react'import{SortableTree,TreeItem}from'@nosferatu500/react-sortable-tree'constinitialData: TreeItem[]=[{title: 'Chicken',children: [{title: 'Egg'}]},{title: 'Fish',children: [{title: 'Fingerling'}]},]exportfunctionExampleTree(){const[treeData,setTreeData]=useState(initialData)return(<divstyle={{height: 400}}><SortableTreetreeData={treeData}onChange={setTreeData}/></div>)}Already have a surrounding react-dnd context? Use the context-less export instead:
import{SortableTreeWithoutDndContext}from'@nosferatu500/react-sortable-tree'All props are typed in ReactSortableTreeProps (see src/react-sortable-tree.tsx).
| Prop | Type | Description |
|---|---|---|
treeData | TreeItem[] | Array of tree nodes with { title?, subtitle?, expanded?, children?, ...custom } |
onChange | (treeData: TreeItem[]) => void | Called on every tree data change |
| Prop | Type | Default | Description |
|---|---|---|---|
rowHeight | number | ((treeIndex, node, path) => number) | 62 | Height of each row in pixels |
rowDirection | 'ltr' | 'rtl' | 'ltr' | Layout direction |
scaffoldBlockPxWidth | number | 44 | Width of indent per level |
slideRegionSize | number | 100 | Size of the drag slide region |
style | CSSProperties | - | Styles for the outer container |
innerStyle | CSSProperties | - | Styles for the virtual list |
className | string | - | Class name for the outer container |
| Prop | Type | Description |
|---|---|---|
theme | ThemeProps | Theme object (see Theming section) |
nodeContentRenderer | ComponentType | Custom component for node content |
treeNodeRenderer | ComponentType | Custom component for the entire tree row |
placeholderRenderer | ComponentType | Custom component for empty tree state |
| Prop | Type | Default | Description |
|---|---|---|---|
canDrag | boolean | ((params) => boolean) | true | Whether nodes can be dragged |
canDrop | (params) => boolean | - | Validate if a drop is allowed |
canNodeHaveChildren | (node) => boolean | () => true | Whether a node can have children |
maxDepth | number | - | Maximum nesting depth |
shouldCopyOnOutsideDrop | boolean | ((params) => boolean) | false | Copy node when dropped outside |
dndType | string | - | Custom drag type for multi-tree setups |
onMoveNode | (params) => void | - | Called after a node is moved |
onDragStateChanged | (params) => void | - | Called when drag state changes |
| Prop | Type | Description |
|---|---|---|
searchQuery | string | Search query string |
searchMethod | (params) => boolean | Custom search matching function |
searchFocusOffset | number | Index of the focused match |
searchFinishCallback | (matches) => void | Called when search completes |
onlyExpandSearchedNodes | boolean | Collapse non-matching paths |
| Prop | Type | Description |
|---|---|---|
generateNodeProps | (params) => object | Add custom props to each node |
getNodeKey | (node) => string | number | Generate stable node keys |
onVisibilityToggle | (params) => void | Called when node expands/collapses |
loadCollapsedLazyChildren | boolean | Load lazy children before expanding |
virtuaRef | RefObject<VListHandle> | Direct access to the virtual list |
dragDropManager | object | External react-dnd manager |
The component supports theming through CSS variables, the theme prop, and custom renderers.
Override these CSS variables on the .rst__tree class or a parent element:
.my-custom-theme .rst__tree {
--rst-row-height:62px;
--rst-block-width:44px;
--rst-handle-width:44px;
--rst-line-color:#000;
--rst-line-highlight:#36c2f6;
--rst-line-highlight-arrow: white;
--rst-primary-color:#36c2f6;
--rst-focus-color:#fc6421;
--rst-match-color:#0080ff;
--rst-bg-landing: lightblue;
--rst-bg-cancel:#e6a8ad;
--rst-text-color:#333;
--rst-icon-color:#6DB3F2;
--rst-button-bg:#fff;
--rst-button-border:#989898;
}The theme prop accepts an object with these properties:
typeThemeProps={style?: React.CSSPropertiesinnerStyle?: React.CSSPropertiesscaffoldBlockPxWidth?: numberslideRegionSize?: numbertreeNodeRenderer?: React.ComponentTypenodeContentRenderer?: React.ComponentTypeplaceholderRenderer?: React.ComponentTypedndType?: string}Theme values are merged with component props, with direct props taking precedence.
The library includes a File Explorer theme example in the Storybook demos:
import{SortableTree}from'@nosferatu500/react-sortable-tree'import{fileExplorerTheme,FILE_EXPLORER_THEME_CLASS}from'./themes/file-explorer'functionFileTree(){const[treeData,setTreeData]=useState([{title: 'src',isDirectory: true,expanded: true,children: [{title: 'index.ts'},{title: 'App.tsx'},]},{title: 'package.json'},])return(<divclassName={FILE_EXPLORER_THEME_CLASS}><SortableTreetreeData={treeData}onChange={setTreeData}theme={fileExplorerTheme}rowHeight={28}// Only folders can have childrencanNodeHaveChildren={(node)=>node.isDirectory===true}// Only allow dropping into folderscanDrop={({ nextParent })=>!nextParent||nextParent.isDirectory===true}/></div>)}For dark mode, add the rst__file-explorer-dark class to the wrapper.
To create a custom theme:
- Create a custom
nodeContentRenderercomponent (seesrc/node-renderer-default.tsxfor reference) - Add CSS styles with your theme class
- Export a theme object:
exportconstmyTheme={nodeContentRenderer: MyCustomNodeRenderer,scaffoldBlockPxWidth: 24,slideRegionSize: 50,}Utilities exported from the package:
addNodeUnderParent({ treeData, newNode, parentKey, getNodeKey, expandParent?, addAsFirstChild? })- Add a node under a parentinsertNode({ treeData, newNode, depth, minimumTreeIndex, getNodeKey, expandParent? })- Insert a node at a specific positionremoveNode({ treeData, path, getNodeKey })- Remove a node by pathremoveNodeAtPath({ treeData, path, getNodeKey })- Remove a node at exact pathchangeNodeAtPath({ treeData, path, newNode, getNodeKey })- Update a node at path
getNodeAtPath({ treeData, path, getNodeKey })- Get node at pathgetDescendantCount({ node })- Count all descendantsgetDepth(node)- Get nesting depth of a nodeisDescendant(older, younger)- Check parent-child relationshipgetVisibleNodeCount({ treeData })- Count visible (expanded) nodes
walk({ treeData, getNodeKey, callback, ignoreCollapsed? })- Walk tree depth-firstmap({ treeData, getNodeKey, callback, ignoreCollapsed? })- Transform all nodestoggleExpandedForAll({ treeData, expanded })- Expand or collapse all nodesfind({ treeData, getNodeKey, searchQuery, searchMethod, expandAllMatchPaths? })- Search with path expansion
getFlatDataFromTree({ treeData, getNodeKey, ignoreCollapsed? })- Convert to flat arraygetTreeFromFlatData({ flatData, getKey, getParentKey, rootKey? })- Convert from flat array
defaultGetNodeKey({ treeIndex })- Default key generator (uses index)defaultSearchMethod({ node, searchQuery })- Default search (matches title)
MIT