sortablejs
SortableJS turns an existing DOM container into a reorderable drag-and-drop list. It handles mouse and touch input, animation, drag handles, connected lists, cloning, auto-scrolling, filtering, and optional MultiDrag and Swap behavior without requiring a framework or jQuery. It changes the DOM while a drag happens, then reports the result through events such as onEnd, so application code remains responsible for updating and saving the real data model.
SortableJS remains a capable framework-neutral choice for pointer and touch sorting, especially across connected lists. Do not mistake its smooth DOM behavior for a complete accessible or state-managed interaction; those parts remain application work.
Use it if
- You need reorderable lists in plain JavaScript or across several frontend frameworks and want one DOM-level API
- You need items to move or clone between connected lists with configurable pull and put rules
- You need touch support, auto-scroll, drag handles, animated movement, or nested list behavior without building gesture handling yourself
- You are maintaining an existing SortableJS 1.x integration and value its long-lived API and broad adapter ecosystem
- Keyboard drag-and-drop and screen-reader instructions are release requirements: the README documents pointer, touch, and native HTML5 drag behavior but provides no keyboard interaction or ARIA model, so you must design and test that layer yourself
- Your React or Vue state must remain the only source of truth: SortableJS reorders DOM nodes directly, and its README points framework users to separate wrapper projects; using the core package means carefully reconciling onEnd events with state
- You expect TypeScript types in the package: version 1.15.7 publishes no types field and the README directs users to the separate @types/sortablejs package
- You only need a tiny desktop list: the package ships default AutoScroll and OnSpill plugins, while MultiDrag and Swap have separate mounting and complete-build rules that add API and packaging choices a basic list does not need
- A large unresolved queue makes you nervous: the GitHub repository currently reports 524 open issues and pull requests, substantial triage pressure even though releases and pushes are recent
Setup reality
Installation is one npm package, with no runtime dependencies, native build, credentials, or configuration file. The first surprise is choosing an entry point. import Sortable from 'sortablejs' is the default build and includes AutoScroll and OnSpill. The core ESM build leaves default plugins out, while the complete ESM build also includes MultiDrag and Swap. Named plugin imports must be mounted once with Sortable.mount before an instance uses their options. TypeScript users need @types/sortablejs separately because the npm package has no declarations. SortableJS owns DOM order during a drag, so React, Vue, and other declarative apps must copy evt.oldIndex and evt.newIndex into state or use the framework wrapper linked by the README; otherwise the next render can undo the move. Every sortable item should have a stable data-id if you use toArray, sort, or the store adapter. Empty drop targets need height or padding because emptyInsertThreshold only helps near an actual target box. Touch screens often need delayOnTouchOnly plus a touchStartThreshold, while clickable rows benefit from a handle or fallbackTolerance. There is no persistence by default, and destroy must run when a view unmounts to remove listeners and instance state.
Patterns
Make a list sortablecreate-sortable-list
import Sortable from 'sortablejs';
const list = document.querySelector('#tasks');
const sortable = Sortable.create(list, {
animation: 150,
ghostClass: 'task-ghost',
});SortableJS changes the DOM immediately; this alone does not update an application state array or save the new order.
Apply a completed move to application statesync-array-order
const sortable = Sortable.create(list, {
animation: 150,
onEnd({ oldIndex, newIndex }) {
if (oldIndex == null || newIndex == null || oldIndex === newIndex) return;
const [moved] = tasks.splice(oldIndex, 1);
tasks.splice(newIndex, 0, moved);
saveTasks(tasks);
},
});Indexes count all children unless draggable narrows the set; use oldDraggableIndex and newDraggableIndex when non-draggable children are present.
Keep row text selectable with a handleuse-drag-handle
Sortable.create(list, {
handle: '.drag-handle',
draggable: '.task',
animation: 150,
});
// Each .task contains a button or span with class .drag-handleThe README explains that sortable rows otherwise disable text selection; a handle confines drag initiation to a deliberate target.
Move items between connected listsconnect-two-lists
for (const list of document.querySelectorAll('[data-board-column]')) {
Sortable.create(list, {
group: 'tasks',
animation: 150,
onAdd(event) {
persistMove(event.item.dataset.id, event.to.dataset.boardColumn, event.newIndex);
},
});
}Both lists need the same group name. onAdd runs on the receiving list; use onRemove on the source if that side also needs bookkeeping.
Clone palette items into a canvasclone-between-lists
Sortable.create(palette, {
group: { name: 'blocks', pull: 'clone', put: false },
sort: false,
});
Sortable.create(canvas, {
group: { name: 'blocks', pull: true, put: true },
animation: 150,
});pull: 'clone' copies the DOM element instead of moving it; create a new application-level ID in onAdd so cloned records do not share identity.
Save and restore order with the store adapterpersist-local-order
Sortable.create(list, {
group: 'saved-tasks',
dataIdAttr: 'data-id',
store: {
get(sortable) {
return JSON.parse(localStorage.getItem(sortable.options.group.name) || '[]');
},
set(sortable) {
localStorage.setItem(sortable.options.group.name, JSON.stringify(sortable.toArray()));
},
},
});get runs once during initialization and set runs when a drop ends. Every draggable child needs a unique data-id value.
Apply a stored order programmaticallyrestore-server-order
const sortable = Sortable.create(list, {
dataIdAttr: 'data-id',
animation: 150,
});
const ids = await fetch('/api/task-order').then((r) => r.json());
sortable.sort(ids, true);sort expects string IDs matching data-id attributes. IDs absent from the DOM cannot create items, and unlisted elements remain in the list.
Exclude buttons and inputs from draggingfilter-row-controls
Sortable.create(list, {
filter: 'button, a, input, textarea, [data-no-drag]',
preventOnFilter: false,
onFilter(event) {
if (event.target.matches('[data-delete]')) removeTask(event.item.dataset.id);
},
});preventOnFilter defaults to true and calls preventDefault, which can suppress the normal click or focus behavior of controls unless you change it.
Reject an invalid move in onMoverestrict-drop-target
Sortable.create(list, {
onMove(event) {
const locked = event.related.matches('[data-locked]');
if (locked) return false;
return event.willInsertAfter ? 1 : -1;
},
});Returning false cancels that insertion; -1 forces before and 1 forces after. Leaving the function without a return keeps the default decision.
Reduce accidental drags on touch screenstune-touch-drag
Sortable.create(list, {
delay: 180,
delayOnTouchOnly: true,
touchStartThreshold: 4,
fallbackTolerance: 4,
animation: 150,
});The README suggests a touchStartThreshold of 3 to 5 for sensitive screens; test real devices because delayed native drag behavior varies by browser.
Enable the MultiDrag pluginmount-multidrag
import Sortable, { MultiDrag } from 'sortablejs';
Sortable.mount(new MultiDrag());
Sortable.create(list, {
multiDrag: true,
selectedClass: 'is-selected',
multiDragKey: 'CTRL',
});Mount a plugin once before creating instances. MultiDrag is an extra plugin, not one of the default AutoScroll and OnSpill plugins.
Temporarily disable and then clean updisable-and-destroy
const sortable = Sortable.create(list);
sortable.option('disabled', true);
sortable.option('disabled', false);
// When the view is removed:
sortable.destroy();disabled preserves the instance for later use; destroy removes sortable behavior completely and is the right cleanup when a component unmounts.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| @dnd-kit/core | npm | React applications that need composable sensors and a stronger foundation for keyboard-accessible interactions |
| dragula | npm | Small framework-free projects that want a narrower container-to-container drag API with fewer sorting controls |
| @shopify/draggable | npm | Projects that want separate draggable, sortable, swappable, and droppable modules instead of one list-focused API |