Example
Tree
What the library is for. A drop into a tree has two answers at once — into a node or between two nodes — and several outcomes share the same pixels, told apart by how far right you drag. useTreeDrop resolves all of it into one position.
Nest, un-nest, and reorder documents
Drag a document onto another and it gains children — any node can become a parent. Drag it over its own child and the library refuses: the active subtree is removed from the maths, so a cycle is never offered. Pull left to lift a row out of its parent; the floor drops to the root. On-call declines children (🔒), so the depth stops one level short beside it. Hover a collapsed branch for a moment mid-drag to expand it. The panel under the tree shows the resolved
{ parentId, index, depth, mode } as you drag — the answer the library returns, not the gesture you made.Drag a document — the resolved drop position appears here.
To pick up a draggable item, press Space or Enter. While dragging, use the arrow keys to move the item. Press Space or Enter again to drop, or press Escape to cancel.
'use client'
import type { TreeItem, TreeNestPredicate } from 'fc-react-dnd'
import {
applyTreeDrop,
DndProvider,
DragOverlay,
flattenTree,
TreeDropIndicator,
useActiveDrag,
useDndMonitor,
useTreeDrop,
} from 'fc-react-dnd'
import { useEffect, useMemo, useRef, useState } from 'react'
import { panelStyle } from './_shared/example-styles'
type Doc = { title: string }
const INDENT_PX = 24
const ROW_HEIGHT_PX = 34
const AUTO_EXPAND_DELAY_MS = 600
/** One document declines children, so the depth stops one level short beside it. */
const LOCKED_ID = 'on-call'
const canNest: TreeNestPredicate = (candidateParent) => candidateParent.id !== LOCKED_ID
/** Wider "before/after" bands than the default, because these rows are only 34px tall. */
const NEST_BAND_FRACTION = 0.25
const INITIAL_TREE: readonly TreeItem<Doc>[] = [
{
id: 'handbook',
title: 'Handbook',
children: [
{ id: 'onboarding', title: 'Onboarding' },
{
id: 'engineering',
title: 'Engineering',
children: [
{ id: 'style-guide', title: 'Style guide' },
{ id: 'on-call', title: 'On-call' },
],
},
],
},
{ id: 'roadmap', title: 'Roadmap', children: [{ id: 'q3', title: 'Q3 goals' }] },
{ id: 'meeting-notes', title: 'Meeting notes' },
]
const TreeBoard = () => {
const [items, setItems] = useState(INITIAL_TREE)
const [collapsedIds, setCollapsedIds] = useState<ReadonlySet<string>>(new Set(['roadmap']))
const { projection, getRowProps } = useTreeDrop<Doc>({
items,
collapsedIds,
indentPx: INDENT_PX,
nestBandFraction: NEST_BAND_FRACTION,
canNest,
})
const rows = useMemo(() => flattenTree(items, { collapsedIds }).rows, [items, collapsedIds])
// One walk for both lookups: the title to render, and whether a row has children (so it gets an
// expander). Asking "does this node have children?" per row inside the render loop is the
// quadratic version of the same question.
const { titleById, idsWithChildren } = useMemo(() => {
const titles = new Map<string, string>()
const withChildren = new Set<string>()
const walk = (nodes: readonly TreeItem<Doc>[]) => {
for (const node of nodes) {
titles.set(String(node.id), node.title)
if ((node.children?.length ?? 0) > 0) withChildren.add(String(node.id))
if (node.children) walk(node.children)
}
}
walk(items)
return { titleById: titles, idsWithChildren: withChildren }
}, [items])
/**
* Ids this drag expanded on hover, restored only when the drag ends.
*
* Expanding a branch mid-drag mounts rows, which the library survives; collapsing would unmount
* them, which is a removal, which cancels the drag by design. So the restore waits for
* `onDragEnd`/`onDragCancel`.
*/
const autoExpandedIds = useRef(new Set<string>())
const restoreCollapseState = () => {
const expanded = autoExpandedIds.current
if (expanded.size === 0) return
setCollapsedIds((current) => new Set([...current, ...expanded]))
autoExpandedIds.current = new Set()
}
// Held over a collapsed node that will take children, the branch opens so its children become
// drop targets. Scheduled from an effect rather than during render, so StrictMode's double
// render does not start (and leak) two timers.
const parentUnderPointer = projection?.mode === 'into' ? String(projection.parentId) : null
useEffect(() => {
if (parentUnderPointer === null || !collapsedIds.has(parentUnderPointer)) return
const timer = setTimeout(() => {
autoExpandedIds.current.add(parentUnderPointer)
setCollapsedIds((current) => {
const next = new Set(current)
next.delete(parentUnderPointer)
return next
})
}, AUTO_EXPAND_DELAY_MS)
return () => clearTimeout(timer)
}, [parentUnderPointer, collapsedIds])
useDndMonitor({
onDragEnd: (event) => {
restoreCollapseState()
// `applyTreeDrop` is pure and shares structure — never deep-clone what it returns.
if (projection) setItems((current) => applyTreeDrop(current, event.active.id, projection))
},
onDragCancel: restoreCollapseState,
})
const toggleCollapsed = (id: string) => {
setCollapsedIds((current) => {
const next = new Set(current)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
return (
<div style={{ position: 'relative', maxWidth: 460 }}>
{/* biome-ignore lint/a11y/noNoninteractiveElementToInteractiveRole: fc-react-dnd's documented tree markup — a flat <ul> of <li> read as a tree via role + aria-level/aria-posinset */}
<ul role="tree" style={{ listStyle: 'none', margin: 0, padding: 0 }}>
{rows.map((row) => {
const id = String(row.id)
const { ref, handleProps, isDragging, style } = getRowProps(id)
const hasChildren = idsWithChildren.has(id)
const isCollapsed = collapsedIds.has(id)
return (
// biome-ignore lint/a11y/useFocusableInteractive: the focusable, keyboard-draggable element is the inner handle button; the row carries treeitem semantics for structure
<li
key={id}
role="treeitem"
aria-level={row.depth + 1}
aria-posinset={row.index + 1}
aria-expanded={hasChildren ? !isCollapsed : undefined}
style={{ height: ROW_HEIGHT_PX }}
>
{/*
The measured row spans the full list width and shows depth as padding inside it,
which is what `TreeDropIndicator` needs a fixed origin to indent from. The drag
handle stays on the inner button — measured and grabbed are two jobs.
*/}
<div ref={ref} style={{ display: 'flex', alignItems: 'center', height: '100%' }}>
<span style={{ width: row.depth * INDENT_PX }} aria-hidden="true" />
{hasChildren ? (
<button
type="button"
onClick={() => toggleCollapsed(id)}
aria-label={`${isCollapsed ? 'Expand' : 'Collapse'} ${titleById.get(id) ?? id}`}
style={{ width: 20, border: 0, background: 'none', cursor: 'pointer' }}
>
{isCollapsed ? '▸' : '▾'}
</button>
) : (
<span style={{ width: 20 }} aria-hidden="true" />
)}
<button
type="button"
{...handleProps}
style={{
...style,
flex: 1,
textAlign: 'left',
font: 'inherit',
border: 0,
background: 'none',
cursor: 'grab',
opacity: isDragging ? 0.4 : 1,
}}
>
{titleById.get(id) ?? id}
{id === LOCKED_ID ? (
<span title="Declines children" aria-hidden="true">
{' 🔒'}
</span>
) : null}
</button>
</div>
</li>
)
})}
</ul>
<TreeDropIndicator projection={projection} />
{/*
Tree rows never move during a drag — they are measure-only, and moving one would move the
very geometry the projection is computed from. So the thing under the cursor is a separate
overlay; without one, the only feedback is a line jumping around a list that stays still.
*/}
<DragOverlay>
<TreeDragPreview
titleById={titleById}
depth={projection?.depth ?? 0}
parentTitle={
projection?.parentId != null
? (titleById.get(String(projection.parentId)) ?? String(projection.parentId))
: null
}
/>
</DragOverlay>
<pre style={{ ...panelStyle, marginTop: 16 }}>
{projection
? JSON.stringify(
{
parentId: projection.parentId,
index: projection.index,
depth: projection.depth,
mode: projection.mode,
},
null,
2,
)
: 'Drag a document — the resolved drop position appears here.'}
</pre>
</div>
)
}
const TreeDragPreview = ({
titleById,
depth,
parentTitle,
}: {
titleById: ReadonlyMap<string, string>
depth: number
parentTitle: string | null
}) => {
const active = useActiveDrag()
if (!active) return null
const id = String(active.id)
const isNesting = parentTitle !== null
return (
<div
style={{
marginLeft: depth * INDENT_PX,
height: ROW_HEIGHT_PX,
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '0 12px',
borderRadius: 6,
background: '#fff',
border: `2px solid ${isNesting ? '#2563eb' : '#cbd5e1'}`,
boxShadow: '0 8px 24px rgba(15,23,42,0.18)',
font: 'inherit',
whiteSpace: 'nowrap',
cursor: 'grabbing',
}}
>
<span aria-hidden="true">⠿</span>
{titleById.get(id) ?? id}
{isNesting ? (
<span style={{ color: '#2563eb', fontSize: 12 }}>↳ into {parentTitle}</span>
) : (
<span style={{ color: '#64748b', fontSize: 12 }}>top level</span>
)}
</div>
)
}
export const TreeExample = () => (
<DndProvider>
<TreeBoard />
</DndProvider>
)