fc-react-dndnpm ↗
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.
src/examples/tree-example.tsx
'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>
)