dnd-kit interop
A snapgrid grid is dnd-kit. Every tile is a real useSortable, so a grid composes with the
rest of the dnd-kit ecosystem under one DragDropProvider: drag a card from a useSortable tray
into the grid (it lands at a real cell, with compaction), drag a tile back out, or reorder the tray
— all in the same drag.
Managed vs. onDragOver
For pure snapgrid grids you don’t write any drag handlers — the grid’s managed engine drives
in-grid moves, cross-grid hand-off, and resizing,
and reports results through onLayoutChange. That still applies here: an
in-grid move falls through to the engine untouched.
Interop with a foreign dnd-kit container (a sortable list, a kanban column) is different. The two
sides have separate state shapes — a grid is a Layout, a list is a string[] — so you own the
seam between them, in the provider’s onDragOver. snapgrid gives you the reducers; you wire them to
the source/target types you care about.
Reduce the cross-parent move live in onDragOver, not on drop. dnd-kit reparents the dragged
DOM node mid-drag; if you only update state on drop, React’s next render fights that reparent and
throws removeChild. Reducing live keeps React in sync with dnd-kit the whole drag.
One provider
The grid and the foreign sortable must share a single dnd-kit manager, so they live under one
DragDropProvider (or snapgrid’s SnapGridGroup, a thin wrapper). Import
the dnd-kit primitives from @snapgridjs/react so everything resolves the same dnd-kit instance —
a second copy of @dnd-kit/react is a separate provider context and its drags never reach the grid.
import { DragDropProvider } from "@dnd-kit/react";
import { useSortable } from "@dnd-kit/react/sortable";
import { move } from "@dnd-kit/helpers";
import {
defaultGridConfig,
removeItemWithCompactor,
snapMove,
toPositionParams,
useGridContainer,
useGridItem,
verticalCompactor,
} from "@snapgridjs/react";The grid accepts foreign sortables
By default a grid only accepts grid tiles and external-drop
draggables. Opt a foreign sortable in with the accept option on useGridContainer — it widens
which sources resolve the grid as a drop target (the built-in checks and the nested-grid
ancestry guard still apply). You drive the actual receive yourself with snapMove (below).
const { containerProps, group } = useGridContainer({
layout,
width,
onLayoutChange: setGrid,
// Accept the tray's cards (type "tray-card") as drop targets.
accept: (source) => source.type === "tray-card",
});The tray card is a plain useSortable. Give it a type you’ll branch on, and have it accept grid
tiles so a tile can be dropped onto it:
function TrayCard({ id, index }: { id: string; index: number }) {
const { ref } = useSortable({
id,
index,
group: "tray",
type: "tray-card",
accept: ["tray-card", "grid-item"],
});
return <div ref={ref}>{id}</div>;
}snapgrid tiles use type: "grid-item" and the grid container uses type: "grid". Branch on those
in onDragOver alongside your own types. Item ids must be unique across the whole provider —
a card in the tray and a tile in the grid can’t share an id.
The onDragOver reducer
Each seam is one branch keyed on source.type → target.type. snapMove brings an item into
the grid; removeItemWithCompactor takes one out; dnd-kit’s move reorders the list. In-grid
moves fall through to the managed engine.
const positionParams = toPositionParams({ ...defaultGridConfig, ...GRID }, width);
<DragDropProvider
onDragOver={(event) => {
const { source, target } = event.operation;
if (!source || !target) return;
const id = String(source.id);
// Tray card → grid: drop it out of the tray, into the layout at the hovered cell.
if (source.type === "tray-card" && (target.type === "grid" || target.type === "grid-item")) {
setTray((t) => t.filter((x) => x !== id));
setGrid((g) =>
snapMove(g, event, {
positionParams,
compactor: verticalCompactor,
defaultItem: { w: 2, h: 2 },
}),
);
}
// Grid tile → tray: remove it AND re-pack the hole, then insert into the tray.
else if (source.type === "grid-item" && target.type === "tray-card") {
setGrid((g) =>
removeItemWithCompactor(g, id, { compactor: verticalCompactor, cols: GRID.cols }),
);
setTray((t) => (t.includes(id) ? t : insertBefore(t, id, String(target.id))));
}
// Reorder within the tray.
else if (source.type === "tray-card" && target.type === "tray-card") {
setTray((t) => move(t, event));
}
// In-grid moves fall through — the grid's engine drives them.
}}
>
{/* grid host + tray, rendered inside the provider */}
</DragDropProvider>Use removeItemWithCompactor, not layout.filter(...), to take a tile out. A plain filter
leaves a hole where the tile was; removeItemWithCompactor removes it and re-packs the
remainder so the grid stays tidy — the same compaction the managed engine applies on a cross-grid
move.
snapMove
snapMove is the 2-D grid analog of dnd-kit’s move(items, event) — call it from onDragOver
(or onDragEnd) to place the dragged item into a Layout at the cell under the pointer, with
compaction, and get the new layout back.
function snapMove(layout: Layout, event: SnapMoveEvent, ctx: SnapMoveContext): Layout;
interface SnapMoveContext {
positionParams: PositionParams; // the grid's geometry — build with toPositionParams
compactor: Compactor; // e.g. verticalCompactor
defaultItem?: { w: number; h: number }; // size for a foreign source (default 1×1)
gridRect?: { left: number; top: number }; // override the grid's client rect (else read from the target)
}The item’s size comes from a dragged grid tile’s payload when present (so a tile keeps its size as
it crosses), else from defaultItem for a foreign card. Build positionParams from the same
width and config the grid renders at, so the dropped cell lines up with what the reader sees.
The drag preview
There’s no overlay to wire up — each draggable floats itself, and the preview stays under the
pointer across the hand-off. Grid tiles are positioned with left/top (not a transform) precisely
so dnd-kit’s self-float reads each tile’s true rect; when a tile becomes a tray card mid-drag (or
vice versa), the floating preview tracks the pointer cleanly instead of jumping. This is the same
mechanism dnd-kit’s own flow-positioned sortables use to hand off between lists.
Beyond a tray
The same three reducers cover any grid ⇄ sortable seam — a kanban board whose columns are sortable
lists and whose cards drop into a dashboard grid, a sidebar of blocks, a list view that toggles to a
grid view. Branch on whatever types your containers carry; snapMove / removeItemWithCompactor
handle the grid side, dnd-kit’s move handles the list side.