dnd-kit interop
A snapgrid grid is dnd-kit. Every tile is a real createSortable, so a grid composes with the
rest of the dnd-kit ecosystem under one DragDropProvider: drag a card from a createSortable 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, Svelte’s {#each} reconciler fights that
reparent and throws removeChild. Reducing live keeps Svelte 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/svelte so everything resolves the same dnd-kit instance —
a second copy of @dnd-kit/svelte is a separate provider context and its drags never reach the grid.
import { DragDropProvider } from "@dnd-kit/svelte";
import { createSortable } from "@dnd-kit/svelte/sortable";
import { move } from "@dnd-kit/helpers";
import {
createGridContainer,
createGridItem,
removeItemWithCompactor,
snapMove,
verticalCompactor,
} from "@snapgridjs/svelte";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 createGridContainer — 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 container = createGridContainer(() => ({
layout,
width,
onLayoutChange: (next) => (grid = next),
// Accept the tray's cards (type "tray-card") as drop targets.
accept: (source) => source.type === "tray-card",
}));The tray card is a plain createSortable. Give it a type you’ll branch on, and have it accept grid
tiles so a tile can be dropped onto it:
<!-- TrayCard.svelte -->
<script lang="ts">
import { createSortable } from "@dnd-kit/svelte/sortable";
let { id, index }: { id: string; index: number } = $props();
const card = createSortable({
get id() {
return id;
},
get index() {
return index;
},
group: "tray",
type: "tray-card",
accept: ["tray-card", "grid-item"],
});
</script>
<div {@attach card.attach}>{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.
<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.
// snapMove resolves the grid's geometry + compactor from the target grid itself.
// (The grid CONTAINER is the drop target — tiles never are — so branch on "grid".)
if (source.type === "tray-card" && target.type === "grid") {
tray = tray.filter((x) => x !== id);
grid = snapMove(grid, event, { 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") {
grid = removeItemWithCompactor(grid, id, { compactor: verticalCompactor, cols: GRID.cols });
tray = tray.includes(id) ? tray : insertBefore(tray, id, String(target.id));
}
// Reorder within the tray.
else if (source.type === "tray-card" && target.type === "tray-card") {
tray = move(tray, 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.
By default it resolves the destination grid from event.operation.target (the grid the pointer
is over) and reads that grid’s geometry, compactor, and default item size from what it already
renders with — so there’s nothing to rebuild or keep in sync. Every context field is optional and
overrides the resolved value.
function snapMove(layout: Layout, event: SnapMoveEvent, ctx?: SnapMoveContext): Layout;
interface SnapMoveContext {
positionParams?: PositionParams; // omit → the target grid's geometry
compactor?: Compactor; // omit → the target grid's compactor
defaultItem?: { w: number; h: number }; // foreign-source size; omit → grid's dropConfig.defaultItem, then 1×1
gridRect?: { left: number; top: number }; // override the grid's client rect (else its element)
manager?: object; // rarely needed — the target entity already references its manager
}The item’s size comes from a dragged grid tile’s payload when present (so a tile keeps its size as
it crosses), else defaultItem for a foreign card. Pass positionParams / compactor explicitly
only to drive a grid that isn’t mounted (e.g. in tests) or to override what the target grid uses.
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.