shadcn-virtualized-tree
v0.1.0
Published
Headless and styled virtualized tree components for React
Downloads
135
Maintainers
Readme
shadcn-virtualized-tree
A typed React tree library with a headless state hook and an optional virtualized UI. It is designed for large hierarchical datasets while keeping tree data, async loading, and application state under your control.
Features
- Headless
useTreehook for custom renderers and design systems - Optional fixed-row virtualized tree UI
- Thousands of nodes with configurable overscan
- Controlled or uncontrolled expansion and selection
- No selection, single selection, or multiple selection
- Optional descendant selection propagation
- Tri-state parent selection: unchecked, mixed, and checked
- Disabled nodes excluded from selection propagation
- Immutable move, reorder, and reparent operations
- Desktop drag-and-drop ordering in the styled component
- Lazy-loading indicators driven by application state
- Arrow-key navigation and keyboard activation
- ARIA tree metadata and mixed checkbox semantics
- Custom node data, labels, icons, and label rendering
- Replaceable structural icons from any React icon library
- Theme tokens, CSS variables, and class-based styling
- Configurable height, row height, indentation, inner padding, radius, and overscan
Installation
Install from npm
npm install shadcn-virtualized-treeImport the hook/component and the default styles:
import { useTree, VirtualizedTree } from "shadcn-virtualized-tree";
import "shadcn-virtualized-tree/styles.css";The styled component includes Lucide icons as defaults. They can be replaced completely through icon slots.
Install from GitHub
You can also install directly from GitHub if you want the latest unreleased changes:
npm install github:usamakhangt4/shadcn-virtualized-tree#mainThe repository's prepare script builds the distributable files automatically during installation. Imports use the package name normally:
import { useTree, VirtualizedTree } from "shadcn-virtualized-tree";
import "shadcn-virtualized-tree/styles.css";For reproducible production installs, pin a release tag or commit instead of main:
npm install github:usamakhangt4/shadcn-virtualized-tree#COMMIT_SHAQuick start
import { useState } from "react";
import {
useTree,
VirtualizedTree,
type TreeNode,
} from "shadcn-virtualized-tree";
import "shadcn-virtualized-tree/styles.css";
export function FileTree() {
const [nodes, setNodes] = useState<TreeNode[]>([
{
id: "src",
label: "src",
children: [
{ id: "components", label: "components", children: [
{ id: "tree.tsx", label: "tree.tsx" },
] },
{ id: "index.ts", label: "index.ts" },
],
},
]);
const tree = useTree({
nodes,
onNodesChange: setNodes,
selectionMode: "multiple",
selectParents: true,
selectDescendants: true,
defaultExpandedIds: ["src", "components"],
});
return (
<VirtualizedTree
tree={tree}
height={360}
showCheckboxes
showIcons
/>
);
}Tree data
Each node has a stable, globally unique id.
interface TreeNode<T = unknown> {
id: string;
label: React.ReactNode;
children?: TreeNode<T>[];
childrenCount?: number;
disabled?: boolean;
icon?: React.ElementType;
data?: T;
}childrencontains loaded descendants.childrenCountmarks a node expandable before its children are loaded.disabledprevents selecting or dragging that node.iconaccepts a component from any React icon library.datacarries application-specific typed metadata.
Duplicate IDs throw an error during indexing.
Headless usage
useTree supplies tree state and algorithms without requiring the default renderer.
const tree = useTree({ nodes, selectionMode: "single" });
return tree.flatNodes.map(({ node, depth }) => (
<button
key={node.id}
disabled={node.disabled}
style={{ paddingInlineStart: depth * 20 }}
onClick={() => tree.toggleSelected(node.id)}
>
{node.label}
</button>
));The returned API includes:
flatNodes— currently visible nodes with depth and ARIA position metadatanodeMap— node lookup by IDparentMap— parent lookup by node IDexpandedIds,selectedIds, andindeterminateIdstoggleExpanded(id)andtoggleSelected(id)setExpandedIds(ids)andsetSelectedIds(ids)moveNode({ nodeId, targetId, position })
Selection behavior
const tree = useTree({
nodes,
selectionMode: "multiple",
selectDescendants: true,
selectParents: true,
});selectionMode supports "none", "single", and "multiple".
With selectDescendants, selecting a branch selects every loaded, enabled descendant.
With selectParents, parent checkboxes are derived from their enabled children:
- No children selected → unchecked
- Some children selected → indeterminate
- Every enabled child selected → checked
The headless hook exposes mixed parents through indeterminateIds. The styled component renders a minus icon and sets aria-checked="mixed".
Controlled state
Selection and expansion can be controlled independently.
const [selectedIds, setSelectedIds] = useState(new Set<string>());
const [expandedIds, setExpandedIds] = useState(new Set(["src"]));
const tree = useTree({
nodes,
selectedIds,
onSelectedChange: setSelectedIds,
expandedIds,
onExpandedChange: setExpandedIds,
selectionMode: "multiple",
});Use defaultSelectedIds and defaultExpandedIds when internal state is preferred.
Lazy loading
Network behavior remains application-owned. Set childrenCount, react to expansion, update nodes, and pass active request IDs to the renderer.
const [loadingIds, setLoadingIds] = useState(new Set<string>());
const handleExpandedChange = async (next: Set<string>) => {
setExpandedIds(next);
if (!next.has("cloud") || loadingIds.has("cloud")) return;
setLoadingIds(current => new Set(current).add("cloud"));
try {
const children = await loadChildren("cloud");
setNodes(current => replaceChildren(current, "cloud", children));
} finally {
setLoadingIds(current => {
const copy = new Set(current);
copy.delete("cloud");
return copy;
});
}
};
const tree = useTree({
nodes,
expandedIds,
onExpandedChange: handleExpandedChange,
});
return <VirtualizedTree tree={tree} loadingIds={loadingIds} />;While a node is loading, its chevron is replaced by a spinner and expansion clicks are disabled.
Ordering and reparenting
Enable native desktop drag-and-drop in the styled tree:
const tree = useTree({ nodes, onNodesChange: setNodes });
return <VirtualizedTree tree={tree} enableOrdering />;Drop zones support before, inside, and after. Self-drops and drops into descendants are rejected. Moving inside a branch expands the target.
For custom renderers or sensor-based drag-and-drop libraries, call the immutable operation directly:
tree.moveNode({
nodeId: "report.pdf",
targetId: "archive",
position: "inside",
});Native HTML drag-and-drop is desktop-oriented. Use a sensor-based library such as dnd-kit for touch ordering and call moveNode when the gesture completes.
Custom labels and node actions
renderLabel can return any React content.
<VirtualizedTree
tree={tree}
renderLabel={node => (
<>
<span>{node.label}</span>
<small>{node.children ? "Group" : "Item"}</small>
<button onClick={event => {
event.stopPropagation();
toggleDisabled(node.id);
}}>
{node.disabled ? "Enable" : "Disable"}
</button>
</>
)}
/>Stop propagation on nested actions when clicking them should not select the row.
Custom icon libraries
Lucide icons are defaults, not a requirement. Replace any or all structural icons with components from React Icons, Material Icons, Font Awesome, or another React-compatible library.
<VirtualizedTree
tree={tree}
icons={{
chevron: MyChevron,
folder: MyFolder,
folderOpen: MyOpenFolder,
file: MyFile,
check: MyCheck,
indeterminate: MyMinus,
grip: MyDragHandle,
loader: MySpinner,
}}
/>Icon components receive common size and className props. A node-level icon overrides the default file icon for that node.
Colors and theming
Colors are arbitrary tokens, not limited to the playground presets.
<VirtualizedTree
tree={tree}
theme={{
accent: "#10b981",
focusRing: "#34d399",
selectedForeground: "#a7f3d0",
selectedBackground: "rgb(16 185 129 / 18%)",
hoverBackground: "rgb(16 185 129 / 10%)",
dropBackground: "rgb(16 185 129 / 25%)",
background: "#07130f",
foreground: "#a7bdb4",
border: "#164e3d",
muted: "#5d7c70",
}}
/>Available CSS variables are:
--svt-background
--svt-foreground
--svt-border
--svt-muted
--svt-hover-background
--svt-selected-background
--svt-selected-foreground
--svt-focus-ring
--svt-accent
--svt-drop-backgroundUse className, style, the variables above, or replace the included stylesheet entirely.
VirtualizedTree props
| Prop | Default | Purpose |
| --- | --- | --- |
| tree | required | API returned by useTree |
| height | 400 | Viewport height in pixels |
| rowHeight | 32 | Fixed row height in pixels |
| overscan | 6 | Extra rows rendered above and below the viewport |
| indent | 20 | Indentation per hierarchy level |
| viewportPadding | 8 | Inner gutter around virtual rows |
| rowRadius | "medium" | "none", "medium", or "full" row radius |
| showIcons | true | Display folder and file icons |
| showCheckboxes | false | Display checkbox state |
| enableOrdering | false | Enable native desktop drag-and-drop |
| loadingIds | — | IDs whose chevrons should show loaders |
| icons | Lucide defaults | Partial structural icon-slot overrides |
| theme | dark defaults | Color-token overrides |
| renderLabel | node label | Custom row-label renderer |
| onActivate | — | Called when a row is activated |
| className | — | Viewport class name |
| style | — | Viewport inline styles |
Keyboard and accessibility
Arrow DownandArrow Upmove through visible nodes.Arrow Rightexpands a branch or moves to its first visible child.Arrow Leftcollapses a branch or moves to its parent.EnterandSpacetoggle selection.- Focused virtual rows are automatically scrolled into view.
- The container uses
role="tree"andaria-activedescendant. - Rows expose level, position, set size, expansion, selection, disabled, and mixed-checkbox metadata.
Utility exports
The package also exports pure helpers:
indexTree(nodes)flattenTree(nodes, expandedIds)getDescendantIds(node)isDescendant(parentMap, ancestorId, nodeId)moveTreeNode(nodes, instruction)
These are useful for custom renderers, reducers, tests, and non-React state layers.
Current constraints
- Virtualization requires fixed row heights.
- Selection propagation only includes children currently present in
nodes; apply selection when lazy children arrive if required. - Built-in drag-and-drop uses native HTML drag events and is not a touch implementation.
- Tree data is immutable and consumer-owned; async errors, persistence, and server synchronization remain application concerns.
Development
npm install
npm test
npm run typecheck
npm run build
npm run playgroundThe playground is deployed automatically from main through GitHub Pages.
