Skip to Content
New — snapgrid now ships for Svelte 5: the same dnd-kit-native, headless-first grid — drag, resize, repack, and drag between grids. See it in Svelte →
SvelteDocumentationGuidesdnd-kit interop

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.

Sortable ↔ grid
drag a widget into the grid · drag a tile out to the tray · reorder the tray

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

Last updated on