GridsExperimental
Two kinds of grid. A wrapping gallery is still a sortable list, so it stays on reorderable with axis: 'wrap'. A dashboard where items own cells and spans is placementGrid, which previews the whole reflow as derivation and writes your signal once, at drop.
@mmstack/dndnpm
This API surface is experimental: it may still change and is not yet battle-tested in production. Pin a version and expect some churn.
Wrap grids
# Set axis: 'wrap' and the pointer engine switches to a 2D collision model: item centers measured at drag start become fixed slots, the dragged tile resolves to the nearest slot, and displaced siblings glide to their new slot even across row boundaries. Same controller, same directives, same single splice at drop.
Demo loads on scroll.
import { Reorderable, ReorderableItem, reorderable } from '@mmstack/dnd';
protected readonly gallery = reorderable(this.tiles, {
engine: 'pointer',
key: (t) => t.id,
axis: 'wrap',
}); Keyboard follows the geometry: Left and Right step the reading order, Up and Down move to the nearest tile in the adjacent row. Wrap lists join a sortableGroup like any other list, so rows drag from a vertical tray into the grid and back. The slot model is exact for uniform tiles and a good approximation for mixed sizes, the same trade dnd-kit's rect sorting strategy makes. Wrap is pointer-engine territory; combining it with the native indicator engine falls back to vertical behaviour with a dev warning.
The placement grid
#placementGrid is the dashboard model: items carry a cell rect (x, y, w, h in grid cells) on a fixed column count, and you own the array signal. Dragging projects the pointer to a cell and previews the reflow as pure derivation. Nothing touches your signal until the drop, which commits in one write with untouched items reference-identical. Resize by the corner grip; arrows move a focused widget one cell and Shift with an arrow resizes it.
Demo loads on scroll.
import {
PlacementGrid,
PlacementGridItem,
PlacementGridResizeHandle,
placementGrid,
type GridPlacement,
} from '@mmstack/dnd';
type Widget = GridPlacement & { id: string; label: string };
@Component({
imports: [PlacementGrid, PlacementGridItem, PlacementGridResizeHandle],
template: `
<div [mmPlacementGrid]="grid">
@for (w of grid.items(); track w.id) {
<div class="widget" [mmPlacementGridItem]="w">
{{ w.label }}
<i class="grip" mmPlacementGridResizeHandle="se"></i>
</div>
}
</div>
`,
})
class Dashboard {
private readonly widgets = signal<Widget[]>([
{ id: 'chart', label: 'Chart', x: 0, y: 0, w: 4, h: 2 },
{ id: 'kpis', label: 'KPIs', x: 4, y: 0, w: 2, h: 1 },
]);
protected readonly grid = placementGrid(this.widgets, {
key: (w) => w.id,
cols: 6,
gap: 8,
rowHeight: 52,
});
}The projection only fires when the pointer crosses a cell, so wiggling inside one recomputes nothing. The container directive sizes itself to the preview rows and exposes the live cell units, which is how the demo draws the drop-target outline you see while dragging.
<!-- #gridRef="mmPlacementGrid" exposes the live cell units -->
@if (target(); as cell) {
<i
class="target"
[style.transform]="'translate(' + cell.x * (gridRef.units.unitX() + 8) + 'px, ...'"
[style.width.px]="cell.w * gridRef.units.unitX() + (cell.w - 1) * 8"
></i>
}
// component: the projected cell plus the active item's spans
target() {
const key = this.grid.activeKey();
const cell = this.grid.projectedCell();
const item = this.grid.previewLayout().find((w) => w.id === key);
return key !== null && cell && item
? { x: cell.x, y: cell.y, w: item.w, h: item.h }
: null;
} A move released outside the grid cancels. As soon as the pointer leaves, the preview shows the original layout again, dropOutside() turns true and projectedCell() turns null, so the outline above goes away. Let go there and nothing is written; bring the pointer back over the grid to carry on with the move. Outside means outside the grid's box as it was when the drag started, so a preview that grows under the pointer can't flip the answer. Resizes are exempt (pulling the grip past the edge is how you reach the widest span), Escape cancels any gesture, and over another member of the grid's group a release is a transfer, covered below.
Two compaction modes
#compact: 'vertical' is the default: colliding widgets push down to make room and gravity pulls everything up on commit, the feel you know from dashboard builders. compact: 'none' is the form-builder personality: nothing else moves, a cell is either free or the move is rejected, and the projection sticks to the last valid cell. During a drag the grid grows by the dragged item's height so a drop below the last row is always reachable and every affordance stays inside the border.
placementGrid(this.slots, {
key: (w) => w.id,
cols: 8,
compact: 'none',
canPlace: (item, x, y) => x !== 7, // your rules on top of the overlap check
});
// while dragging: grid.targetMask() is a Uint8Array of valid originstargetMask() exposes the validity mask while dragging (mask[y * cols + x]), so free cells can light up, and a canPlace predicate layers your own placement rules on top of the built-in overlap check.
Commits and events
# A drop commits over the live source. The preview is built from the layout as it was when the drag started; on release the same move, or resize, is run again against the current array, so an item another writer added, removed, or edited during the drag survives the drop. When someone else wrote during the drag, the committed layout can therefore differ from the preview you saw. That is the honest outcome. If the dragged item was removed meanwhile, its target cell was taken under compact: 'none', or canPlace now refuses the move, the drop does nothing. The item on the event is the live item at the moment of the drop, so a concurrent edit to it is not lost either.
A drop that changes nothing is silent. If the settled layout puts every item at the same address (x, y, w, h) it had before, as when a widget is wiggled and released on its own cell, no onPlace or onResize fires and the source is not written. That holds in both compaction modes and for keyboard moves.
onPlace and onResize both report displaced: the other items whose address the gesture changed by consequence, pushed or pulled by the reflow, each as { key, from, to } in source order. That tells "moved by the user" apart from "moved because of it". The dragged item is never listed, and under compact: 'none' nothing else moves, so the list is empty.
placementGrid(this.widgets, {
key: (w) => w.id,
cols: 12,
onPlace: ({ item, x, y, displaced }) => {
log(`${item.id} moved to ${x},${y}`);
for (const d of displaced) log(`${String(d.key)} now on row ${d.to.y}`);
},
}); Grid events carry addresses, not neighbour anchors as list events do: a grid has no order to name a position by. Duplicate keys work as in lists: each item is its own occurrence, and displaced[].key is a handle, the raw key unless that key has duplicates.
Grids in a group
# A placement grid is a sortableGroup member. Put it in the same group as a palette or tray list and cards drop onto the grid at the pointed cell, while widgets dragged out of the grid become list rows. Two knobs tune the crossing: the grid exposes crossTarget(), true while its drag hovers another member, so you can restyle the dragged widget into a preview of the row it will become; and the list can set insertSize so the gap it opens matches that compact row instead of the widget's full grid size. A widget dragged into a list lands at the neighbour the hover pointed at, as in list-to-list drops, even if the list changed in between.
const group = sortableGroup<Widget>();
const grid = placementGrid(this.widgets, { key, cols: 12, group });
const tray = reorderable(this.trayItems, {
key,
engine: 'pointer',
group,
insertSize: 50, // arrivals open a row-sized gap, not a widget-sized one
});
// template: morph the dragged widget while it hovers the tray
// [class.as-row]="grid.crossTarget() && grid.activeKey() === w.id"The playground's placement grid page has the full version of this, morph and all. For the drag-and-drop fundamentals underneath, see sortable lists.