dnd-tree-sortable
v2.0.0
Published
Accessible React sortable trees with folder explorer behavior, virtualization, and lazy children
Maintainers
Readme
Sortable Tree Registry
A shadcn registry for a React drag-and-drop tree using ScrollArea and Tailwind CSS. Install the source into your project, customize the rows, and own the component.
- Nested sorting with pointer and keyboard controls
- Native scrolling through shadcn ScrollArea
- Collapsible and removable nodes
- Controlled state for saving changes
- Custom row content and controls
- Tailwind styling with shadcn theme tokens, Button variants, and Badge
Install from the registry
Use a React 18.3 or React 19 project initialized with shadcn/ui, with Tailwind CSS 4 and shadcn theme tokens configured. Install from the hosted registry:
pnpm dlx shadcn@latest add https://ductinhkzz.github.io/dnd-tree-sortable/r/sortable-tree.jsonThe CLI installs dependencies, adds shadcn scroll-area, button, and badge as registry dependencies, and copies the tree into your configured components directory under dnd-tree-sortable. The manifest uses @components/ targets, so both project-root and src/components layouts work. Imports use your configured aliases; the example below assumes @/components. Existing shadcn primitives are reused; review any CLI overwrite prompt if you have customized those files.
You can also configure a namespace in your consumer project's components.json:
{
"registries": {
"@dnd-tree": "https://ductinhkzz.github.io/dnd-tree-sortable/r/{name}.json"
}
}Then run pnpm dlx shadcn@latest add @dnd-tree/sortable-tree. This is a user-configured namespace; it is not registered in shadcn's public registry index.
Usage
'use client';
import { useState } from 'react';
import { SortableTree, type TreeItem } from '@/components/dnd-tree-sortable';
export function TreeDemo() {
const [items, setItems] = useState<TreeItem[]>([
{
id: 'Pages',
children: [
{ id: 'Introduction', children: [] },
{ id: 'Installation', children: [] },
],
},
{ id: 'Components', children: [] },
]);
return (
<SortableTree items={items} onItemsChange={setItems} height={400} indentationWidth={24} collapsible removable />
);
}Every item must have a unique string or numeric id and a children array. Extra fields are retained when items move. Use TreeItem<YourData> and components.ItemContent to render custom data. Keep IDs and structure stable during a drag.
For uncontrolled use, pass defaultItems instead of items. defaultItems is only read on mount. onItemsChange reports the entire tree after sorting, collapsing, removing, or loading children. When using items, update it in onItemsChange.
Props
| Prop | Purpose | Default |
| ------------------- | ------------------------------------------------------------- | ----------------- |
| loadChildren | Async child loader; receives the folder and an AbortSignal | — |
| mode | reorder, tree, or explorer movement rules | tree |
| items | Controlled tree data | — |
| defaultItems | Initial uncontrolled tree | [] |
| onItemsChange | Receives the updated full tree | — |
| height | Scroll viewport height | 400 |
| className | Override the ScrollArea container with Tailwind classes | — |
| aria-label | Accessible name for the sortable list | Sortable items |
| emptyMessage | Content shown when no items remain | No items yet. |
| indentationWidth | Pixels per nesting level | 24 |
| collapsible | Show collapse controls | false |
| removable | Show removal controls | false |
| indicator | Show a drop marker in reorder/tree modes | false |
| components | Replace Handle, Collapse, Remove, ItemContent, or Badge | Built-in controls |
| renderItemContent | Replace the whole row; forward supplied refs and handle props | Built-in row |
| onChange | Legacy callback with the moved item's before/after position | — |
| onRemove | Receives the removed ID | — |
Choose a movement mode for your use case:
| Mode | Behavior |
| ---------------- | ------------------------------------------------------------------------------------------------- |
| reorder | Reorder siblings; the parent stays fixed. |
| tree (default) | Reorder and change nesting, including ancestor moves. |
| explorer | Move files/folders into a different folder or workspace root; existing sibling order stays fixed. |
Focus a drag handle and press Space or Enter to pick up. Up/Down chooses a target, Space/Enter drops, and Escape cancels. Left/Right changes nesting in tree mode; horizontal arrows have no effect in reorder mode. Keyboard targets scroll into view.
The component uses semantic utilities such as bg-background, text-foreground, bg-accent, and ring-ring. It inherits the consumer's light/dark theme, border radius, and color tokens. Controls have visible keyboard focus, item-specific labels, expanded state, and decorative icons hidden from assistive technology. Reorder and hierarchy use a labeled list; Explorer uses tree/treeitem roles, hierarchy metadata, and a single row tab stop with arrow-key navigation. Action uses the native button with shadcn buttonVariants so handle refs work on both React 18 and React 19.
Virtuoso and CSS Modules have been removed. Large trees now use TanStack Virtual inside the shadcn ScrollArea; small trees render all expanded nodes. The npm stylesheet compiles the same Tailwind classes without adding a global preflight reset. Registry consumers use their existing Tailwind pipeline and do not import that stylesheet.
Explorer and large trees
Use mode="explorer" for folder/file icons, single selection, click-to-toggle folders, arrow-key navigation, Home/End, typeahead, and Enter/double-click file activation. Supply kind: "folder" for empty folders and kind: "file" for leaves. Nodes without kind are inferred as folders when they have children. Files cannot receive children during a drag. Folders expand after a 700 ms drag hover; cancelling the drag keeps that expansion.
<SortableTree
mode='explorer'
defaultItems={[
{ id: 'src', kind: 'folder', children: [{ id: 'main', label: 'main.tsx', kind: 'file', children: [] }] },
{ id: 'empty', kind: 'folder', children: [] },
]}
onSelect={(item) => console.log('Select', item.id)}
onOpen={(file) => console.log('Open', file.id)}
virtualize={{ threshold: 200, estimateSize: 36, overscan: 8 }}
/>selectedId controls selection; update it from onSelect. items/onItemsChange continue to control tree structure. Opening a file calls onOpen; the host application supplies its editor or preview. Rename/create and multi-selection are outside this first Explorer implementation. Folder rows use a borderless Explorer layout, colored file-type icons, and a rounded selection highlight. Drag the row to move it; Alt+M reveals and focuses the keyboard move handle. Space/Enter picks up, Up/Down chooses a folder, and Left chooses workspace root. Row arrow keys navigate the Explorer. Folder drops append to the destination and expand it; dropping into the current parent does nothing. Files cannot receive children and folders cannot move into their own descendants.
Virtualization activates automatically at 200 visible nodes in all modes, so collapsed descendants do not count. Set virtualize={true} to always virtualize or virtualize={false} to render every visible row. Rows are measured for custom heights. Focused rows and drag sources stay mounted; keyboard targets are revealed before the drag moves. Custom renderItemContent implementations must forward wrapperRef, wrapperStyle, data-index, and the supplied accessibility/event props to the outer row (the built-in row does this). The playground includes Reorder, Hierarchy, Folders, and 1,000-file examples.
Dynamic child loading
Mark a folder with childrenLoaded: false, start it collapsed, and provide loadChildren. Only expanded, visible folders request children. Unmarked items retain their existing behavior.
import { SortableTree, type LoadChildren } from '@/components/dnd-tree-sortable';
const loadChildren: LoadChildren = async (folder, { signal }) => {
const response = await fetch(`/api/tree/${encodeURIComponent(String(folder.id))}`, { signal });
if (!response.ok) throw new Error('Could not load children');
return response.json(); // TreeItem[] with globally unique IDs
};
<SortableTree
mode='explorer'
defaultItems={[{ id: 'src', kind: 'folder', collapsed: true, childrenLoaded: false, children: [] }]}
loadChildren={loadChildren}
/>;The loader returns the folder's immediate children. Return more collapsed folders with childrenLoaded: false to load deeper levels on demand. A visible folder that starts expanded loads automatically; loading a parent does not fetch its collapsed descendants.
Successful responses set childrenLoaded: true in the tree, including empty results. Pending requests are deduplicated and re-expansion uses the cached children. Rows show a spinner and aria-busy while loading; failures keep the tree intact and expose a keyboard-accessible retry button. A failed request does not retry automatically.
Collapsing a folder keeps its request running and caches the response without reopening it. Removing the folder, unmounting the tree, or removing loadChildren aborts its request; late responses are discarded even if the loader ignores the signal. Responses merge with the latest tree so moves, renames, and other loads are preserved. Files moved into a loading folder remain there. Duplicate IDs are treated as a load failure.
Controlled trees must apply onItemsChange to retain loaded children. To refetch a completed folder, set its childrenLoaded back to false (also clear children if the remote result should replace cached contents). Use a new component key when replacing the entire data source, which also cancels outstanding requests. Virtualization automatically handles large loaded results. The Dynamic loading playground starts with four folders and includes nested loading, 1,000 fetched files, an empty result, and a retry example.
Develop and build
Use Node.js 24 LTS (see .nvmrc) and pnpm 12.10.1 (pinned in package.json). Node.js 22.13+ is also supported. From the repository root:
pnpm install --frozen-lockfile
pnpm run devpnpm dev, pnpm test, and pnpm test:watch rebuild the registry before starting. The pnpm lockfile replaces package-lock.json; CI installs with --frozen-lockfile.
Visit http://localhost:5173/dnd-tree-sortable/. The live homepage includes preview/code tabs, theme switching, collapse/removal controls, and a copyable install command that uses the current origin.
pnpm run format:check
pnpm run lint
pnpm run typecheck
pnpm run build-docs
pnpm test
pnpm run buildregistry.json is the catalog and source manifest. It reads the canonical implementation in lib/; there is no second copy to maintain. Add every new source file to its files array with an @components/dnd-tree-sortable/ target. Registry coverage tests catch omitted files. The local shadcn primitive files in lib/components/ui and npm-only package.ts, styles.css, and theme.css are excluded from the registry; registryDependencies installs the primitives in consumer projects.
shadcn build generates docs/public/r/registry.json and docs/public/r/sortable-tree.json; these are generated artifacts and are not committed. build-docs rebuilds the registry before compiling the website so docs/dist includes both the website and registry JSON.
Test from a disposable, initialized consumer project:
pnpm dlx shadcn@latest add http://localhost:5173/dnd-tree-sortable/r/sortable-tree.jsonCI checks formatting, lint, file naming, types, the latest commit message, website/registry build, Vitest tests, the library build, and production dependency advisories. The existing GitHub Pages workflow deploys docs/dist after merging to master, using GitHub's official Pages artifact deployment with scoped Pages/OIDC permissions. The registry is available publicly only after that deployment succeeds.
Install from npm
Install the npm library:
pnpm add dnd-tree-sortable@^2 react react-domImport the component and its stylesheet:
import { SortableTree } from 'dnd-tree-sortable';
import 'dnd-tree-sortable/dist/assets/style.css';The registry flow copies Tailwind-styled source and installs shadcn primitives. The npm flow bundles the local primitive source and provides precompiled CSS; existing consumers keep the stylesheet import above. The release workflow validates the source, packed consumers, and matching version tag before publishing. Packing or building locally does not publish.
Credits
Built on the dnd-kit sortable tree example with shadcn ScrollArea. Registry format follows the shadcn registry guide.
Tooling and naming
- Oxlint checks TypeScript, React hooks, accessibility, and Vitest code. Vite 8 uses the Oxc transform pipeline.
- Oxfmt replaces Prettier. Run
pnpm run formatto write changes, orpnpm run format:checkto verify them. - Vitest replaces the Node/tsx test runner.
pnpm testrebuilds the registry and runs utility, registry, SSR, and React interaction tests;pnpm run test:watchwatches tests. - commitlint enforces Conventional Commits through Husky's
commit-msghook. Use messages such asfeat: add custom tree rowsorfix: preserve numeric item ids. CI checks the newest commit; existing repository history is not rewritten. - The pre-commit hook checks formatting, lint, and types.
pnpm run checkruns all project checks. - File and folder names use kebab-case, enforced by
scripts/check-file-names.mjs. Standard dotfiles/config extensions and the conventionalREADME.md,CHANGELOG.md,CONTRIBUTING.md, andLICENSEnames are allowed. Component/export names remain PascalCase.
Dependency review — October 10, 2026
Direct versions were checked against npm's stable latest tags. The lockfile records the tested versions.
| Area | Version | Review |
| ------------------------------------------ | --------------- | --------------------------------------------------------------------------- |
| React / React DOM | 19.3.0 | Site and interaction tests migrated from React 18 |
| Vite / React plugin | 8.3.4 / 6.1.2 | Oxc/Rolldown build; removed old Rollup plugin configuration |
| TypeScript | 7.0.2 | Removed baseUrl; direct declaration generation replaces vite-plugin-dts |
| Vitest | 5.0.3 | Node utility/SSR tests and jsdom interaction tests |
| Oxlint / Oxfmt | 1.87.0 / 0.72.0 | Replaces ESLint and Prettier |
| commitlint / Husky | 21.2.3 / 9.1.7 | Conventional Commit validation and local hooks |
| Tailwind CSS / Vite plugin | 4.3.3 | Replaces CSS Modules with semantic theme utilities |
| Radix UI | 1.7.0 | Official shadcn ScrollArea, Button, and Badge foundations |
| Lucide / cn | 1.54.0 / 0.4.0 | Theme-aware icons and the class merge helper used by current shadcn source |
| dnd-kit React / DOM / abstract / collision | 0.5.0 | New architecture; removed core, sortable, and utilities packages |
| TanStack Virtual | 3.14.14 | Headless virtualization within the shadcn ScrollArea |
| shadcn | 4.21.4 | Latest stable registry CLI |
Removed react-virtuoso, rc-scrollbars (its peer range stops at React 18), ESLint and its plugins, Prettier, tsx, glob, the Rollup visualizer, and vite-plugin-dts. The unused React Pages/MDX docs tree, old docs lockfile, template pages, and redundant TypeScript configs were removed. Runtime dependencies now live in dependencies, and the npm build externalizes them rather than copying them into the bundle.
Migrated to the new dnd-kit React API: DragDropProvider, @dnd-kit/react/sortable, ref-based handles, and the new DOM sensors/accessibility plugin. Rows explicitly disable optimistic DOM sorting so React owns row order. Up/down keys follow visible tree order; left/right keys change nesting; Escape cancels. Pointer collision detection uses pointer distance to visible rows. Subtrees and custom payloads are preserved on drop.
Custom components.Handle implementations receive elementRef in ActionProps; attach it to the handle button (<button ref={elementRef}>) or pass all props through the exported Action component. Custom renderItemContent implementations must forward ref, wrapperRef, and handleProps.elementRef to their source, target wrapper, and handle. The obsolete classic drop animation was removed; the overlay ends immediately on drop. Registry installs use only the new dnd-kit architecture.
Logic and UX review
- Moves run through a pure
moveTreeItemhelper. Stale IDs, hidden descendant targets, invalid offsets/indentation, and unchanged drops are ignored safely. - The legacy positional callback now receives recomputed depth and sibling index for the moved node.
- Nesting into a collapsed parent expands it after a successful drop so the result stays visible.
- Flattening uses one accumulator and descendant exclusion uses a Set, avoiding repeated array copies and linear membership scans.
- Drag placeholders retain their row height. Optional before/after markers show the projected nesting level without shrinking the list.
- Collapse/removal controls are disabled during a drag; keyboard Escape cancels the move without applying a reorder. Hover-expanded Explorer folders remain open.
- Tests cover tree invariants across reorder/nesting combinations, real ScrollArea and sensor interactions, numeric IDs, custom payloads/handles, controlled updates, cancellation, empty states, and SSR.
pnpm audit --prod reports zero production vulnerabilities. The full pnpm audit reports one high-severity development-only advisory for braces, reached through three shadcn dependency paths (fast-glob, ts-morph, and @shadcn/registry). This is the same advisory previously reported as seven package-level entries by npm. The installed latest shadcn version still carries them; no forced downgrade or unverified override was applied. Track GHSA-vfj7-8cjw-p6xm and update the CLI when a safe upstream fix is available.
Migrating from 1.x
The rework targets 2.0.0 because custom rendering and drag integration changed. Keep the public npm import and explicit stylesheet import; reinstall from the registry if you own the copied source.
- Replace classic dnd-kit integrations with the new React/DOM API. Forward
handleProps.elementRefor customActionProps.elementRefto the handle button. Custom rows must forward the sourceref, targetwrapperRef,wrapperStyle,data-index, and accessibility/event props. - Update CSS Modules overrides to Tailwind classes/shadcn tokens. Registry projects require Tailwind 4; npm projects use the supplied compiled stylesheet and do not need Tailwind.
- Replace Virtuoso/rc-scrollbars integrations with
virtualizeand the built-in ScrollArea. No Virtuoso instance is exposed. - Use controlled
itemswithonItemsChangefor persistence. The positionalonChangecallback remains available but does not report collapse, removal, or lazy loading. - Prefer public exports. Internal filenames use kebab-case and deep npm paths are not exported. The npm package is ESM.
Contributing and releases
See CONTRIBUTING.md for setup, source conventions, consumer checks, and the release process. CHANGELOG.md tracks the upcoming major release and subsequent changes. Both npm and registry distributions include the MIT license.
Before releasing, run pnpm check, pnpm check:package, and pnpm audit:production. The registry is self-hosted: deploy docs/dist and verify its public installation URL. Registering a namespace in shadcn's public index is optional and separate from serving the registry.
