@pyreon/dnd — API Reference
Generated from
dnd'ssrc/manifest.ts— the same source that powersllms.txtand MCPget_api. Do not edit this page by hand; edit the manifest. For the conceptual guide, see dnd.
Signal-driven drag and drop for Pyreon. A thin wrapper over Atlassian's pragmatic-drag-and-drop (the engine behind Trello / Jira): pdnd owns the native-event lifecycle, hit-testing, and edge detection; @pyreon/dnd adapts every state field into a Pyreon signal accessor (isDragging / isOver / activeId / overId / overEdge / dragData) and wires every pdnd teardown into onCleanup. Five hooks cover the common surfaces — single draggable, single drop target, sortable list with edge detection + auto-scroll + keyboard reordering + opt-in cross-list boards, native-file drop with MIME / count filtering, and a page-global drag monitor.
Features
useDraggable / useDroppable / useSortable / useFileDrop / useDragMonitor — five hooks over pragmatic-drag-and-drop
Every drag-state field is a fine-grained signal accessor (isDragging / isOver / activeId / overId / overEdge / dragData)
Sortable: auto-scroll near container edges, closest-edge detection, Alt+Arrow keyboard reordering, ARIA wiring
Cross-list boards via groupId — onCrossListDrop (source removes) + onCrossListReceive (destination inserts)
Native file drop with accept (extension / MIME glob / exact MIME) and maxFiles filtering + page-wide isDraggingFiles
Automatic teardown via onCleanup — sortable disposes per-item AND container pdnd registrations individually (churning <For> lists and <Show>-toggled containers never leak)
SSR-safe: every hook short-circuits on the server and returns inert zero-state accessors
Complete example
A full, end-to-end usage of the package:
import { signal } from '@pyreon/reactivity'
import { For } from '@pyreon/core'
import { useDraggable, useDroppable, useSortable } from '@pyreon/dnd'
// Single draggable — element is a GETTER, state is a signal accessor:
function Card(props: { card: { id: string; title: string } }) {
let el: HTMLElement | null = null
const { isDragging } = useDraggable({
element: () => el,
data: { id: props.card.id, type: 'card' },
})
return (
<div ref={(node) => (el = node)} class={() => (isDragging() ? 'opacity-50' : '')}>
{props.card.title}
</div>
)
}
// Drop target — canDrop filters, sourceData is DragData (narrow it yourself):
function DropZone() {
let el: HTMLElement | null = null
const { isOver } = useDroppable({
element: () => el,
canDrop: (data) => data.type === 'card',
onDrop: (data) => acceptCard(data.id as string),
})
return <div ref={(node) => (el = node)} class={() => (isOver() ? 'bg-blue-50' : '')}>Drop here</div>
}
// Sortable list — keyed like <For by>, hook computes the reorder, YOU commit it:
const cols = signal<Column[]>([])
const { containerRef, itemRef, isActive, isOverKey, overEdge } = useSortable({
items: () => cols(),
by: (c) => c.id,
onReorder: (next) => cols.set(next),
axis: 'vertical',
})
;<ul ref={containerRef}>
<For each={cols()} by={(c) => c.id}>
{(col) => (
<li
ref={itemRef(col.id)}
class={isActive(col.id) ? 'dragging' : ''}
style={() => (isOverKey(col.id) && overEdge() === 'top' ? 'border-top: 2px solid blue' : '')}
>
{col.name}
</li>
)}
</For>
</ul>Exports
| Symbol | Kind | Summary |
|---|---|---|
useDraggable | hook | Make an element draggable with signal-driven state. |
useDroppable | hook | Make an element a drop target with signal-driven hover state. |
useSortable | hook | Full reorderable list — pointer dragging, auto-scroll near container edges, closest-edge detection, Alt+Arrow keyboard r |
useFileDrop | hook | Native-file drop zone over pdnd's external/file adapter — accepts files dragged in from the OS, not in-page draggables. |
useDragMonitor | hook | Observe every element drag on the page without owning a draggable or drop target — for global overlays, analytics, or co |
API
useDraggable hook
<T extends DragData = DragData>(options: UseDraggableOptions<T>) => UseDraggableResultMake an element draggable with signal-driven state. element is a GETTER (() => el) captured on the next microtask, so the element only has to exist by mount time — not at hook-call time. data is the transferred payload: pass a plain object for static payloads or a function for dynamic ones (resolved fresh at each drag start via pdnd's getInitialData). handle scopes drag initiation to a sub-element; disabled accepts a reactive () => boolean re-evaluated on every drag attempt via canDrag. Returns { isDragging } — a signal accessor that is true while THIS element is dragged. onDragEnd fires on both drop and cancel.
Example
let el: HTMLElement | null = null
const { isDragging } = useDraggable({
element: () => el,
data: () => ({ id: card.id, position: position() }), // getter → resolved per drag start
handle: () => handleEl, // only this sub-element starts a drag
disabled: () => isSaving(), // reactive — checked on every drag attempt
onDragEnd: () => console.log('released (drop OR cancel)'),
})
;<div ref={(node) => (el = node)} class={() => (isDragging() ? 'opacity-50' : '')}>
{card.title}
</div>Common mistakes
Passing the element itself instead of a getter —
element: elcapturesnull(refs are not populated at hook-call time); passelement: () => elso the deferred microtask setup reads the mounted nodePassing an object
dataand expecting it to track current state — the object form is captured once at hook-call time; use the function formdata: () => ({ id: item.id(), position: position() })for dynamic payloads (resolved fresh at each drag start)Passing a captured boolean for
disabledwhen you want live toggling —disabled: isSaving()snapshots once;disabled: () => isSaving()is re-evaluated on every drag attemptSwapping the ref to a NEW DOM node after mount — registration happens exactly once on the next microtask; a later element change is not re-registered (unmount/remount the component instead)
Treating
onDragEndas drop-only — it fires on BOTH a successful drop and a cancelled drag
See also: useDroppable · useSortable · useDragMonitor
useDroppable hook
<T extends DragData = DragData>(options: UseDroppableOptions<T>) => UseDroppableResultMake an element a drop target with signal-driven hover state. canDrop(sourceData) filters incoming drags — when it returns false the target won't highlight, onDragEnter won't fire, and a drop won't land. data (value or getter) is attached to the target so a useDragMonitor's onDrop can read target metadata. Returns { isOver } — true only while an ACCEPTED draggable hovers this target. All callbacks receive the source's data as the wide DragData (Record<string, unknown>) — pdnd erases the source's generic across the drag boundary.
Example
let el: HTMLElement | null = null
const { isOver } = useDroppable({
element: () => el,
data: { columnId: props.id }, // readable by useDragMonitor's onDrop target arg
canDrop: (source) => source.type === 'card', // reject anything that isn't a card
onDrop: (source) => props.onAdd(source.id as string),
})
;<div ref={(node) => (el = node)} class={() => (isOver() ? 'bg-blue-50' : '')}>
Drop a card here
</div>Common mistakes
Trusting
sourceDataas your draggable's typedT— it arrives asDragData(Record<string, unknown>); narrow with a discriminant (source.type === 'card',typeof source.id === 'string') before reading fieldsExpensive
canDroppredicates — it runs on every drag event; derive a cheap flag in an upstreamcomputedfor costly checksExpecting
isOverto flip for rejected drags — it only tracks ACCEPTED draggables; whencanDropreturnsfalse,onDragEnternever fires and there's no highlight
See also: useDraggable · useDragMonitor
useSortable hook
<T>(options: UseSortableOptions<T>) => UseSortableResultFull reorderable list — pointer dragging, auto-scroll near container edges, closest-edge detection, Alt+Arrow keyboard reordering, and ARIA wiring (role="listitem", aria-roledescription, tabindex) — driven from a reactive items() getter. by extracts the stable key and MUST match your <For by> key. On drop the hook computes the reordered array and calls onReorder(next) — it never mutates your list; you commit it. groupId opts two sortables into one cross-list drop universe (Trello-style boards): the destination's onCrossListReceive(item, index) inserts, the source's onCrossListDrop(item) removes. Returns containerRef (scroll container), itemRef(key) (per-row ref factory), itemHandleRef(key) (optional grip-handle registrar scoping drag initiation), the activeId / overId / overEdge signal accessors, and createSelector-backed isActive(key) / isOverKey(key) predicates (O(2) notifies per change — prefer them over activeId() === key, which subscribes EVERY row). Drags are announced to screen readers via @pyreon/a11y (label: (item) => string names the item; a visually-hidden Alt+Arrow instructions node is auto-created and linked via aria-describedby).
Example
const items = signal([{ id: '1', name: 'Alice' }, { id: '2', name: 'Bob' }])
const { containerRef, itemRef, activeId, overId, overEdge } = useSortable({
items, // reactive getter — signals are callable
by: (item) => item.id, // MUST match the <For by> key
onReorder: (next) => items.set(next), // hook hands you a NEW array; you commit
axis: 'vertical', // 'horizontal' flips edges + arrow keys
label: (item) => item.name, // names items in screen-reader announcements
})
;<ul ref={containerRef}>
<For each={items()} by={(item) => item.id}>
{(item) => (
<li
ref={itemRef(item.id)}
class={isActive(item.id) ? 'dragging' : ''}
style={() => (isOverKey(item.id) ? `border-${overEdge()}: 2px solid blue` : '')}
>
{item.name}
</li>
)}
</For>
</ul>Common mistakes
Passing a captured array snapshot as
items— the hook re-derives on every drop / keypress, soitemsmust be a reactive getter (items: () => cols()or the signal itself); a snapshot breaks reorderingMismatched keys —
bymust return the same stable key your<For by>uses, or the list tears on reorderExpecting the hook to mutate your list —
onReorder(next)hands you a NEW array; commit it yourself (items.set(next)) or nothing visibly reordersExpecting cross-list drops without
groupId—onCrossListDrop/onCrossListReceiveonly fire whengroupIdis set; without it each sortable is a private universe that rejects drags from other sortablesForgetting
containerRefon the scroll container — auto-scroll, the reorder-finalizing drop target, and the Alt+Arrow keyboard handler all register thereBinding rows with
activeId() === item.idinstead ofisActive(item.id)— the equality read subscribes EVERY row toactiveId(O(N) binding re-runs per drag change); thecreateSelector-backed predicates notify only the two affected rowsOmitting
labelwhen keys are opaque ids — screen-reader announcements fall back to the raw key ("Picked up 41f3…"); passlabel: (item) => item.nameCalling
itemRefwith a different key thanbyreturns — the drop-time reorder lookup finds items by that key (findIndexagainstby(item)), so a mismatch makes reorders silently no-op and mistracks per-key disposal
See also: useDraggable · useDragMonitor
useFileDrop hook
(options: UseFileDropOptions) => UseFileDropResultNative-file drop zone over pdnd's external/file adapter — accepts files dragged in from the OS, not in-page draggables. accept filters like <input accept>: leading . matches the extension (case-insensitive), trailing /* is a MIME glob, anything else is an exact MIME type. maxFiles truncates to the first N. Returns TWO signal accessors: isOver (files hovering THIS zone) and isDraggingFiles (files dragged anywhere on the page — for a page-wide 'drop ready' affordance). onDrop receives the filtered, truncated files and only fires when at least one file survives.
Example
let zone: HTMLElement | null = null
const { isOver, isDraggingFiles } = useFileDrop({
element: () => zone,
accept: ['image/*', '.pdf'], // MIME glob OR extension
maxFiles: 5, // silently truncates to the first 5
onDrop: (files) => upload(files),
})
;<div
ref={(node) => (zone = node)}
class={() => (isOver() ? 'drop-active' : isDraggingFiles() ? 'drop-ready' : '')}
>
Drop files here
</div>Common mistakes
Expecting it to catch in-page draggables —
useFileDropuses pdnd's external/file adapter and only fires for REAL file drags from the OS;useDraggableitems go through the isolated element adapterRelying on
onDropfor rejection feedback — files rejected byaccept/maxFilesare silently filtered, andonDropdoes not fire at all when zero files survive; check counts insideonDrop(or pair withisOver) to surface errorsWriting
accept: ['pdf']— extensions need the leading dot ('.pdf'); a bare string is treated as an exact MIME type and matches nothingExpecting
maxFilesto reject an over-count drop — it TRUNCATES to the first N and discards the rest; enforce hard limits insideonDropyourself
See also: useDroppable · useDragMonitor
useDragMonitor hook
(options?: UseDragMonitorOptions) => UseDragMonitorResultObserve every element drag on the page without owning a draggable or drop target — for global overlays, analytics, or coordinating multiple drag areas. canMonitor(data) filters which drags this monitor reacts to (a false return means isDragging / dragData don't flip and no callback fires). onDrop(sourceData, targetData) receives the dragged source's data plus the drop target's data — targetData is an empty object {} (not undefined) when the drag ends with no drop target. Unlike the element-bound hooks, the monitor registers immediately (no microtask defer).
Example
const { isDragging, dragData } = useDragMonitor({
canMonitor: (data) => data.type === 'card',
onDrop: (source, target) => track('reorder', { from: source.id, to: target.columnId }),
})
;<Show when={isDragging()}>
<div class="global-drag-overlay">Dragging: {() => String(dragData()?.id ?? '')}</div>
</Show>Common mistakes
Expecting
targetDatato beundefinedon a cancelled drag — it is an empty object{}when there was no drop target, so destructuring is always safe but truthiness checks are notExpensive
canMonitorpredicates — they run on every drag event; keep them cheap or derive a flag upstreamExpecting
dragData()to survive after the drop — it resets tonullthe moment the drag ends; capture what you need insideonDrop
See also: useDraggable · useDroppable · useSortable
Package-level notes
Deferred registration: The element-bound hooks (
useDraggable/useDroppable/useFileDrop) defer pdnd registration to the next microtask sorefcallbacks are populated first — call the hook in the component body and let the ref land.useDragMonitorregisters immediately (it has no element to wait for).
SSR-safe: Every hook short-circuits when
documentis undefined and returns inert zero-state accessors (isDragging: () => false, no-op refs). Real registration happens client-side only — nothing to guard manually.
pdnd not bundled: The three
@atlaskit/pragmatic-drag-and-drop*packages are regular dependencies (installed automatically, never imported directly by consumers) and tree-shake per hook —useDraggablepulls only the element adapter (~6KB min),useFileDroponly the external/file adapter,useSortableadds auto-scroll + hitbox.
No dispose(): Cleanup is wired into Pyreon's
onCleanup— teardown fires when the owning component unmounts. There is no public teardown method; unmount the component (e.g. behind<Show>) to stop a drag interaction.