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 | null#Options
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.
#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();