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.

Drag a row (grip handle)

Demo loads on scroll.

component
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.

Drag between columns

Demo loads on scroll.

component
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.

Reorder cards, and rows within them

Demo loads on scroll.

#Horizontal

Set axis: 'x' for a horizontal list. The gap and the drop position follow the axis.

Drag a chip sideways

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.

styles
.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.

component
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.

OptionWhat 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();