Sortable lists
reorderable() turns a WritableSignal<T[]> into a sortable list. One controller covers handles, cross-list moves, nesting, and both axes.
@mmstack/dndnpm
A single list
# Call reorderable with your array signal and a key. Bind it with mmReorderable on the container and mmReorderableItem on each row. The engine: 'pointer' option gives you the FLIP animation. Reordering is one splice on your signal at drop.
Demo loads on scroll.
import { Reorderable, ReorderableItem, reorderable } from '@mmstack/dnd';
@Component({
imports: [Reorderable, ReorderableItem],
template: `
<ul [mmReorderable]="list">
@for (task of list.items(); track task.id) {
<li [mmReorderableItem]="task">{{ task.label }}</li>
}
</ul>
`,
})
class TaskList {
private readonly data = signal([{ id: 1, label: 'First' }]);
protected readonly list = reorderable(this.data, {
engine: 'pointer',
key: (t) => t.id,
axis: 'y',
});
} Add mmReorderableHandle to an element inside a row to restrict the grab area, which keeps the rest of the row scrollable on touch.
Between lists
# Share one sortableGroup<T>() across two lists and items drag between them. Each list still owns its own signal.
Demo loads on scroll.
import { reorderable, sortableGroup } from '@mmstack/dnd';
const group = sortableGroup<Card>();
const todo = reorderable(todoSignal, { engine: 'pointer', key: (c) => c.id, group });
const done = reorderable(doneSignal, { engine: 'pointer', key: (c) => c.id, group });Nested lists
# Nested lists work by nesting the bindings. For nested lists in one group the innermost target wins, and you can gate moves with a canReceive guard. Here each card holds its own checklist, and checklist items move between cards.
Demo loads on scroll.
Horizontal
# Set axis: 'x' for a horizontal list. The gap and the drop position follow the axis.
Demo loads on scroll.
reorderable(tags, { engine: 'pointer', key: (t) => t.id, axis: 'x' });Styling hooks
# The engine is headless. It adds one class to the dragging element, mm-sortable-dragging, and exposes the reserved gap size as the --mm-sortable-reserved custom property so a container can grow as the gap opens. Everything else is your CSS.
.item.mm-sortable-dragging {
box-shadow: 0 8px 24px rgb(0 0 0 / 18%);
}
.list {
padding-bottom: calc(8px + var(--mm-sortable-reserved, 0px));
}reorderable or injectReorderable
#reorderable is a pure factory. It reads no DI, which keeps it easy to test but means it does not see the app-wide defaults you may have set. To have a list pick up provideDndDefaults and provideReorderableDefaults, call injectReorderable instead. Same signature: it captures the current Injector and hands it to reorderable for you. A per-call option still wins over any default.
import { injectReorderable } from '@mmstack/dnd';
class TaskList {
private readonly data = signal([{ id: 1, label: 'First' }]);
// resolves provideDndDefaults / provideReorderableDefaults for this list
protected readonly list = injectReorderable(this.data, { key: (t) => t.id });
} Set the defaults with provideReorderableDefaults, and read the resolved value back with injectReorderableDefaults (pass an Injector to read outside an injection context). These cover the reorderable-only options; the shared engine comes from provideDndDefaults unless this provider sets it.
import {
injectReorderableDefaults,
provideReorderableDefaults,
} from '@mmstack/dnd';
provideReorderableDefaults({ axis: 'x', animation: { duration: 150 } });
const resolved = injectReorderableDefaults(); // ReorderableDefaults | nullOptions
# Beyond key, engine, axis, and group above, the rest of the surface tunes when the insert flips, gates cross-list and external drops, and lets you react to each kind of move.
| Option | What it does |
|---|---|
deadband | Px a center must be cleared by before the insert index flips. Higher values calm a jittery reorder. Default 4. |
activationThreshold | Px the pointer must travel before a drag activates, so a row stays clickable. Pointer engine only. Default 5. |
jumpModifier | Predicate over the keyboard event that decides the jump-to-end key for the built-in handler. Default Cmd on macOS, Ctrl elsewhere. |
canReceive | Cross-list drop guard. Return false to reject an item from another list in the group, and the engine tries the next innermost accepting container. Rejects a tree node dropped into its own subtree, for one. |
insert | Accept a payload dragged from outside any list, like a palette draggable. accepts qualifies the raw payload and create(data, index) maps it to a list item. Native engine only. |
animation | The during-drag reflow glide ({ duration, easing }), or false for instant reflow. This is not a drop animation: the drop commit is instant. Default a decisive 200ms. |
autoScroll | Opt-in edge auto-scroll while dragging, as { edge, speed, edgeProportion?, maxSpeedAt? }. Needs an auto-scroll plugin (see the advanced page); without one a dev warning fires and scrolling no-ops. |
Four callbacks report each kind of move after it commits: onReorder for a same-list move ({ from, to, items }), onItemLeft on the source when an item is dragged into another list, onItemArrived on the target when one arrives, and onItemInserted after an external insert lands. The first three also name the moved item and its new neighbour; see the drop events section below.
Keyboard reordering
# Keyboard reordering is on by default. Focus a row, arrow keys move it one step along the axis, and the jump modifier plus an arrow moves it to an end. Pass jumpModifier to change the jump key. To take over the keys entirely, with your own bindings and behaviour, pass onKeyboardKeydown. It runs instead of the built-in handler and receives an api whose api.move(to) reuses the built-in commit, announce, and focus-restore, so you only decide when to move. Ignore api.move to do something else. Set keyboard: false to drop the keys and the tabindex altogether.
reorderable(this.items, {
key: (t) => t.id,
jumpModifier: (e) => e.shiftKey, // Shift + arrow jumps to an end
onKeyboardKeydown: (e, { index, total, move }) => {
if (e.key === 'j') { e.preventDefault(); move(Math.min(index + 1, total - 1)); }
if (e.key === 'k') { e.preventDefault(); move(Math.max(index - 1, 0)); }
},
});Cancelling a drag
# Pressing Escape aborts an in-flight drag without committing, and so does a pointercancel (a touch scroll taking over, say). Items glide back and nothing is spliced. Only a real release commits. Both engines behave the same way. For a programmatic abort, the controller exposes cancel().
const list = reorderable(this.items, { key: (t) => t.id });
// abort the current drag from code (items glide back, no splice):
list.cancel();When the list changes mid-drag
# The dragged item is its key, read live. Every decision during a drag, the gap, a hand-off to another list, the drop, finds the dragged item by key in the list as it is now, not by the slot it held when the drag began. If another writer, a second tab, an agent, or a resource refresh, inserts or removes rows mid-drag, the drop still moves the item you grabbed. Both engines hand the target the source's live item for that key, never a copy taken when the drag began, so an edit made to the item mid-drag travels with it.
With the pointer engine, a change to the list's keys is re-measured once after it has rendered, and the dragged row stays under the pointer. An edit that keeps the same keys measures nothing. The drag is cancelled, with a dev-mode warning and nothing committed, when the dragged item itself is removed, when the changed list cannot be measured, or when the drop arrives before the change has rendered.
A drop into another list lands where the hover pointed. While you hover, the slot under the pointer is recorded by its neighbour, the item it would land after or before. On release that neighbour is looked up in the target as it is now, so rows inserted there meanwhile do not shift your drop. If the neighbour has been removed, the drop does nothing and warns in dev mode. The native engine does the same for its indicator slot.
Drop events name a neighbour
#onReorder, onItemArrived, and onItemLeft carry two fields beside the indices: moved, the key of the item that moved, and anchor, where it landed by its neighbour: { after: key }, { before: key }, or { empty: true } when it is alone. from, to, and index are unchanged. On onItemLeft the anchor is the place in the destination list, and null when the destination has no order, such as a placement grid.
Apply the anchor, not the index, when the move is written later or somewhere else: a store, a synced document, an undo stack. Once the list has changed an index points somewhere else; a neighbour does not. The anchor has the same shape as the keyed-container Anchor, so moveElement takes it as it is.
import { moveElement, type Anchor } from '@mmstack/primitives';
reorderable(this.view, {
key: (t) => t.id,
// the local list moved already; record the move against the shared container
onReorder: ({ moved, anchor }) => {
// ids are unique here, so moved and the neighbour are plain ids
moveElement(board.tasks, moved as string, anchor as Anchor);
},
}); For lanes you build yourself, the pure anchorOf(keys, moved, index, lane) is exported. Pass 'final' as the lane when index is the moved item's index after the move, or 'slot' when it is an insert slot into the list as it stands.
Duplicate keys
# Identity is the occurrence, not the key. Two rows that share a key are two items, each draggable on its own, and a transfer to another list carries exactly the one you dragged. A rendered row binds to its occurrence by the item reference you pass to mmReorderableItem. If the same object is listed twice, its rows take its occurrences in the order they registered, since nothing else tells them apart.
Events name items by handle: moved and the anchor's neighbour are the raw key while that key never had duplicates, so lists with unique keys see plain keys, and an opaque per-occurrence handle once it did. isKeyHandle(h) from @mmstack/primitives tells them apart, and h.key is the raw key. A handle lasts as long as its item; see handles.