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'
const initialData: TreeItem[] = [
{ title: 'Chicken', children: [{ title: 'Egg' }] },
{ title: 'Fish', children: [{ title: 'Fingerling' }] },
]
export function ExampleTree() {
const [treeData, setTreeData] = useState(initialData)
return (
<div style={{ height: 400 }}>
<SortableTree
treeData={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:
type ThemeProps = {
style?: React.CSSProperties
innerStyle?: React.CSSProperties
scaffoldBlockPxWidth?: number
slideRegionSize?: number
treeNodeRenderer?: React.ComponentType
nodeContentRenderer?: React.ComponentType
placeholderRenderer?: React.ComponentType
dndType?: 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'
function FileTree() {
const [treeData, setTreeData] = useState([
{ title: 'src', isDirectory: true, expanded: true, children: [
{ title: 'index.ts' },
{ title: 'App.tsx' },
]},
{ title: 'package.json' },
])
return (
<div className={FILE_EXPLORER_THEME_CLASS}>
<SortableTree
treeData={treeData}
onChange={setTreeData}
theme={fileExplorerTheme}
rowHeight={28}
// Only folders can have children
canNodeHaveChildren={(node) => node.isDirectory === true}
// Only allow dropping into folders
canDrop={({ 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:
export const myTheme = {
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