Drag-and-drop library integrations
Keep your existing drag-and-drop implementation. Add OpenKeyNav as a small, direct keyboard path.
Your pointer library keeps ownership of dragging, collision detection, animation, previews, and touch behavior. OpenKeyNav selects the two explicit endpoints and translates those DOM elements into the same application operation your existing library adapter already calls.
- OpenKeyNav
- Provides direct keyboard selection of configured source and destination elements.
- You
- Connect those endpoints to the application's existing move operation, validation, focus, and feedback.
ResultPointer, touch, and OpenKeyNav keyboard routes complete the same application workflow.
pointer or touch → drag-and-drop library adapter ─┐
├→ application move operation → state / DOM
keyboard → OpenKeyNav callback ──────────┘
The sortable-library recipes below use one list. Choosing a destination means “move the selected item to the destination item's current position.” The interact.js recipe uses discrete dropzones. Both endpoint models route the interaction through an application operation instead of a fabricated library event.
When a Move Mode configuration supplies callback, OpenKeyNav calls it directly, keeps the synthetic pointer and native drag-event path inactive, and leaves the host library's sensors idle. Omit callback when the interaction is designed for OpenKeyNav's native drag-event simulation path.
Versions and official patterns reviewed
These recipes were checked against the linked official documentation and these current package versions on August 2, 2026. Use the version column as the reviewed baseline and evaluate later library releases against their migration guidance.
| Library | Reviewed package version |
|---|---|
| Pragmatic drag and drop | @atlaskit/pragmatic-drag-and-drop@2.0.1, @atlaskit/pragmatic-drag-and-drop-hitbox@2.0.0 |
| SortableJS | sortablejs@1.15.7 |
| dnd-kit, current API | @dnd-kit/react@0.5.0 |
| dnd-kit, legacy API | @dnd-kit/core@6.3.1, @dnd-kit/sortable@10.0.0 |
| React DnD | react-dnd@16.0.1 |
| Angular CDK | @angular/cdk@22.1.0 |
| interact.js | interactjs@1.10.28 |
Pragmatic drag and drop
This follows Atlassian's official examples and the simple-list source they link to: draggable and drop-target data flows into a top-level monitorForElements({onDrop}) handler.
Keep the edge-aware pointer path and extract its state update into moveTask. When no edge is supplied, the keyboard path infers the edge that moves the source to the target's current position.
const moveTask = useCallback((sourceId, targetId, closestEdge) => {
setTasks((tasks) => {
const startIndex = tasks.findIndex((task) => task.id === sourceId);
const indexOfTarget = tasks.findIndex((task) => task.id === targetId);
if (startIndex < 0 || indexOfTarget < 0 || startIndex === indexOfTarget) {
return tasks;
}
return reorderWithEdge({
list: tasks,
startIndex,
indexOfTarget,
closestEdgeOfTarget:
closestEdge ?? (startIndex < indexOfTarget ? 'bottom' : 'top'),
axis: 'vertical',
});
});
}, []);
The existing monitor remains the pointer adapter. Keep the official type guards used by your application before consuming library data.
useEffect(() => {
return monitorForElements({
onDrop({source, location}) {
const target = location.current.dropTargets[0];
if (
!target ||
!isTaskData(source.data) ||
!isTaskData(target.data)
) {
return;
}
moveTask(
source.data.taskId,
target.data.taskId,
extractClosestEdge(target.data),
);
},
});
}, [moveTask]);
The officially linked task component already renders data-task-id. Point OpenKeyNav at that stable identifier and call the same operation.
useEffect(() => {
const openKeyNav = new OpenKeyNav().init({
modesConfig: {
move: {
config: [{
fromElements: '[data-task-id]',
toElements: '[data-task-id]',
callback(source, destination) {
const sourceId = source.dataset.taskId;
const destinationId = destination.dataset.taskId;
if (sourceId && destinationId) {
moveTask(sourceId, destinationId);
}
},
}],
},
},
});
return () => {
openKeyNav.destroy();
};
}, [moveTask]);
Pragmatic drag and drop's accessibility guidance asks applications to provide an alternative control, communicate the outcome, and restore useful focus. OpenKeyNav supplies the endpoint-selection route; keep the application's validation, announcement, and focus behavior in the shared operation.
SortableJS
This follows the official SortableJS API and Store example. SortableJS reorders the DOM during pointer dragging, calls Store.set(sortable) after the pointer operation, and exposes toArray(), sort(order, useAnimation), and save() for an imperative application path.
Keep the existing Sortable setup. dataIdAttr reads the stable identifier that you provide on each direct list item.
<ul id="task-list">
<li data-id="task-1">First task</li>
<li data-id="task-2">Second task</li>
</ul>
const list = document.querySelector('#task-list');
const sortable = Sortable.create(list, {
animation: 150,
dataIdAttr: 'data-id',
store: {
get: () => loadOrder(),
set: (sortable) => saveOrder(sortable.toArray()),
},
});
Use the documented public methods for the keyboard operation and let the shared Store path persist its result.
function moveToPosition(source, destination) {
const order = sortable.toArray();
const sourceIndex = order.indexOf(source.dataset.id);
const targetIndex = order.indexOf(destination.dataset.id);
if (sourceIndex < 0 || targetIndex < 0 || sourceIndex === targetIndex) {
return;
}
const [sourceId] = order.splice(sourceIndex, 1);
order.splice(targetIndex, 0, sourceId);
sortable.sort(order, true);
sortable.save(); // Calls the same Store.set(sortable) persistence path.
}
const openKeyNav = new OpenKeyNav().init({
modesConfig: {
move: {
config: [{
fromElements: '#task-list > [data-id]',
toElements: '#task-list > [data-id]',
callback: moveToPosition,
}],
},
},
});
For a component that unmounts, clean up both instances with sortable.destroy() and openKeyNav.destroy(). This first recipe intentionally stays within one list; shared lists also need the application's group, cloning, and cross-list persistence rules.
dnd-kit: current React API
The current official API uses DragDropProvider from @dnd-kit/react. This example follows the official manual sortable-state guide and useSortable API.
This recipe uses string IDs in the items array. Extract an ID-based array update from onDragEnd; the functional state update keeps moveItem stable and safe to call from either route.
const moveItem = useCallback((sourceId, targetId) => {
setItems((items) => {
const from = items.indexOf(sourceId);
const to = items.indexOf(targetId);
if (from < 0 || to < 0 || from === to) return items;
const next = [...items];
const [item] = next.splice(from, 1);
next.splice(to, 0, item);
return next;
});
}, []);
Keep the official DragDropProvider event shape. With optimistic sorting, use source.initialIndex and source.index after narrowing with isSortable, then translate the final index back to the stable item ID.
<DragDropProvider
onDragEnd={(event) => {
if (event.canceled) return;
const {source} = event.operation;
if (isSortable(source) && source.initialIndex !== source.index) {
const targetId = items[source.index];
if (targetId != null) {
moveItem(String(source.id), targetId);
}
}
}}
>
{/* sortable list */}
</DragDropProvider>
Expose the existing string ID on the same item node that receives the sortable ref.
function SortableItem({id, index}) {
const {ref} = useSortable({id, index});
return (
<li ref={ref} data-sortable-id={String(id)}>
{id}
</li>
);
}
useEffect(() => {
const openKeyNav = new OpenKeyNav().init({
modesConfig: {
move: {
config: [{
fromElements: '[data-sortable-id]',
toElements: '[data-sortable-id]',
callback(source, destination) {
const sourceId = source.dataset.sortableId;
const destinationId = destination.dataset.sortableId;
if (sourceId && destinationId) {
moveItem(sourceId, destinationId);
}
},
}],
},
},
});
return () => {
openKeyNav.destroy();
};
}, [moveItem]);
dnd-kit's current provider includes pointer and keyboard sensors by default. Keep them enabled and run OpenKeyNav's direct-destination route alongside dnd-kit's spatial keyboard interaction.
dnd-kit: legacy React API
The official site now labels DndContext and @dnd-kit/core as Legacy. Use this recipe for an existing legacy installation; use the previous tab for new @dnd-kit/react code.
Keep the documented DndContext, SortableContext, arrayMove, and sensor configuration. Extract the application operation so both adapters can call it.
const moveItem = useCallback((activeId, overId) => {
if (overId == null || activeId === overId) return;
setItems((items) => {
const from = items.indexOf(activeId);
const to = items.indexOf(overId);
if (from < 0 || to < 0 || from === to) return items;
return arrayMove(items, from, to);
});
}, []);
function handleDragEnd({active, over}) {
moveItem(active.id, over?.id);
}
Keep the official KeyboardSensor and sortableKeyboardCoordinates in the existing sensor list.
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
<SortableContext items={items}>
{/* sortable items */}
</SortableContext>
</DndContext>
Expose the existing string ID on the node receiving setNodeRef, then call moveItem directly.
<div ref={setNodeRef} data-sortable-id={id} {...attributes} {...listeners}>
{id}
</div>
useEffect(() => {
const openKeyNav = new OpenKeyNav().init({
modesConfig: {
move: {
config: [{
fromElements: '[data-sortable-id]',
toElements: '[data-sortable-id]',
callback: (source, destination) =>
moveItem(source.dataset.sortableId, destination.dataset.sortableId),
}],
},
},
});
return () => {
openKeyNav.destroy();
};
}, [moveItem]);
Use string IDs in this form because values read from dataset are strings. OpenKeyNav's direct-destination route runs alongside the library's configured keyboard sensor.
React DnD
This follows React DnD's official Simple Sortable example and useDrop API. That example reorders cards during hover, so preserve the existing pointer adapter as a hover callback.
Keep the official moveCard(dragIndex, hoverIndex) function. Add a class and the current index to the rendered card.
const ref = useRef(null);
const [, drag] = useDrag({
type: ItemTypes.CARD,
item: () => ({id, index}),
});
const [, drop] = useDrop({
accept: ItemTypes.CARD,
hover(item, monitor) {
if (!ref.current) return;
const dragIndex = item.index;
const hoverIndex = index;
if (dragIndex === hoverIndex) return;
const rect = ref.current.getBoundingClientRect();
const middleY = (rect.bottom - rect.top) / 2;
const pointer = monitor.getClientOffset();
if (!pointer) return;
const pointerY = pointer.y - rect.top;
if (dragIndex < hoverIndex && pointerY < middleY) return;
if (dragIndex > hoverIndex && pointerY > middleY) return;
moveCard(dragIndex, hoverIndex);
item.index = hoverIndex;
},
});
drag(drop(ref));
return (
<div ref={ref} className="openkeynav-card" data-index={index}>
{text}
</div>
);
Initialize one OpenKeyNav instance in the list container so every card shares the same integration.
useEffect(() => {
const openKeyNav = new OpenKeyNav().init({
modesConfig: {
move: {
config: [{
fromElements: '.openkeynav-card',
toElements: '.openkeynav-card',
callback(source, destination) {
const from = Number(source.dataset.index);
const to = Number(destination.dataset.index);
if (from !== to) moveCard(from, to);
},
}],
},
},
});
return () => {
openKeyNav.destroy();
};
}, [moveCard]);
In the official example, moveCard is stabilized with useCallback and uses a functional state update. Preserve that shape so the OpenKeyNav effect always reaches the current list state.
Angular CDK Drag and Drop
This follows Angular's official reorderable list example. The CDK directives arrange the pointer interaction, while the documented drop handler updates the data model with moveItemInArray.
Extract that update into moveMovie, then leave the CDK event as a one-line adapter.
moveMovie(from: number, to: number) {
if (from === to) return;
moveItemInArray(this.movies, from, to);
this.changeDetectorRef.markForCheck();
}
drop(event: CdkDragDrop<string[]>) {
this.moveMovie(event.previousIndex, event.currentIndex);
}
Keep cdkDropList and cdkDrag. Add the current index as the OpenKeyNav endpoint identifier.
<div cdkDropList (cdkDropListDropped)="drop($event)">
@for (movie of movies; track movie; let index = $index) {
<div
cdkDrag
class="example-box openkeynav-movie"
[attr.data-index]="index"
>
{{ movie }}
</div>
}
</div>
Use Angular's render and destruction lifecycle for the DOM integration.
import {
afterNextRender,
ChangeDetectorRef,
DestroyRef,
inject,
} from '@angular/core';
import OpenKeyNav from 'openkeynav';
private readonly changeDetectorRef = inject(ChangeDetectorRef);
private readonly destroyRef = inject(DestroyRef);
private openKeyNav?: OpenKeyNav;
constructor() {
afterNextRender(() => {
this.openKeyNav = new OpenKeyNav().init({
modesConfig: {
move: {
config: [{
fromElements: '.openkeynav-movie',
toElements: '.openkeynav-movie',
callback: (source: HTMLElement, destination: HTMLElement) => {
this.moveMovie(
Number(source.dataset.index),
Number(destination.dataset.index),
);
},
}],
},
},
});
});
this.destroyRef.onDestroy(() => this.openKeyNav?.destroy());
}
Angular v21 and later are zoneless by default. Keep markForCheck() in the shared operation, or store the list in a signal, so a document-level OpenKeyNav callback reliably schedules a render. See Framework Integration for the temporary local TypeScript declaration used by the current OpenKeyNav package.
interact.js
This follows the official dropzone documentation. interact.js supplies event.relatedTarget for the dragged element and event.target for the dropzone; application code performs the actual state or DOM update.
Put that update in one function and keep the existing ondrop adapter.
function moveItem(source, destination) {
destination.append(source);
// Reset application-owned transform state here when needed.
}
const dropzones = interact('.dropzone').dropzone({
accept: '.draggable',
ondrop(event) {
moveItem(event.relatedTarget, event.target);
},
});
Use the customization point in moveItem to reset the same JavaScript object, store, or attributes that the existing draggable move listener reads. When that listener keeps no translation state, the DOM append completes the operation.
OpenKeyNav calls moveItem directly while interact.js continues to handle pointer input.
const openKeyNav = new OpenKeyNav().init({
modesConfig: {
move: {
config: [{
fromElements: '.draggable',
toElements: '.dropzone',
callback: moveItem,
}],
},
},
});
Use this recipe for discrete dropzones. Model freeform placement as meaningful application destinations before exposing it through Move Mode. Mirror a simple accept rule with the source and destination selectors. For a custom drop checker or other business rules, call the same validation from both ondrop and the OpenKeyNav callback. On component teardown, call dropzones.unset() and openKeyNav.destroy().
Add the config to one OpenKeyNav instance
The snippets show initialization beside the operation so the shared closure is visible. In the application, initialize one OpenKeyNav instance at the persistent boundary. Fold the relevant config entry into that initializer, or keep the shared move operation in a module/store that the root initializer can call.
If one page has multiple independent move experiences, put multiple objects in modesConfig.move.config, each with selectors scoped to its own container.
Validation and accessibility stay in the application
Place business validation in the shared operation so the drag-and-drop adapter and direct OpenKeyNav callback apply the same canDrop predicates, collision rules, authorization, and persistence behavior. Use selectors to offer valid endpoints from the start.
After integrating:
- Confirm pointer and touch behavior is unchanged.
- Turn OpenKeyNav on with
Shift+O, pressM, choose a source, and choose a destination. - Verify the same data, persistence, validation, and undo behavior runs for both paths.
- Preserve useful focus after rerendering and communicate the completed move through the application's status or live-region pattern.
- Test the complete workflow with keyboard-only use, supported assistive technologies, and disabled users.
Complete the integrated interface with accurate semantics, focus management, validation, announcements, and outcome feedback.