Architecture & dnd-kit
snapgrid is a thin, transparent layer over dnd-kit — not a black box with its own drag engine bolted on. This page is the deep dive: how the pieces fit, and the exact seam a non-React binding (or a direct dnd-kit integration) would build on.
You don’t need any of this to use snapgrid. <GridLayout> and the headless
hooks are the whole API for building grids. This page is for the curious —
and for anyone evaluating how extensible the library really is.
The layers
snapgrid’s runtime is three layered packages (the optional @snapgridjs/extras packers sit alongside):
@snapgridjs/core pure layout math — geometry, compaction, move/resize, and the
drag-session state machine. No dnd-kit. No DOM. No framework.
▲ builds on
@snapgridjs/dnd the engine — wires that math to dnd-kit: one per-manager
drag/resize/cross-grid engine, the observable GridController
render bridge, collision detection, sensors, the snap modifier.
Framework-agnostic (depends on @dnd-kit/dom, not React).
▲ builds on
@snapgridjs/react the binding — hooks + components that register dnd-kit entities
and render from the controller. (depends on @dnd-kit/react)The line that matters is the middle one. The engine has no framework dependency, so a Vue, Solid,
or Svelte binding reuses core + dnd unchanged and only re-implements the thin top layer —
registering entities and rendering. That’s why the drag brain lives in its own package.
A grid is just dnd-kit
The grid is another participant in your dnd-kit tree — there’s no parallel drag system:
| dnd-kit primitive | its role in a grid |
|---|---|
<DragDropProvider> | the grid lives inside it — you supply it |
useSortable | every tile (wrapped by useGridItem) |
useDroppable | the grid surface (created by useGridContainer) |
| collision detection | resolves which grid the pointer is over — the drop target |
Feedback plugin | floats the dragged tile in the top layer (none for keyboard, so it steps in place) |
Each tile is a real useSortable carrying a small payload — { kind, itemId, item, group } — so it
speaks the same protocol as any dnd-kit sortable. That’s what lets a grid compose with sortable lists
and other draggables under one provider, and what makes cross-grid and external drops fall out of
dnd-kit’s own collision system rather than bespoke code.
One engine per manager
The drag brain isn’t in React — it’s a single listener set installed on the dnd-kit manager’s
monitor. attachEngine(manager) wires dragstart / dragmove / dragend (plus a window key
handler for keyboard dragging) and is ref-counted: one engine per manager, no matter how many
grids mount. On each event it:
- resolves the source grid from the drag payload’s
group, and the destination grid from the collision target; - computes the next layout with the pure session functions from
core—beginDrag→dragTo→commitLayoutfor a move, orbeginReceivefor a tile arriving from another grid; - writes the result into the relevant grid’s controller.
Because which grid the pointer is over comes from one collision oracle, the same handlers drive in-grid moves, cross-grid hand-off, and nested grids — no special cases. (One engine routing every grid also means a multi-grid page processes each drag event once, not once per grid.)
The render bridge: GridController
Each grid has a GridController — a small observable store, framework-agnostic. The engine writes
into it; the binding reads from it. It holds:
- the committed layout (the source of truth),
- the live drag session (the preview shown while dragging),
- the published per-grid config (geometry, compactor, gates, callbacks) the engine reads back.
It exposes subscribe plus value-cached snapshots (renderedSnapshot(), itemSnapshot(id)), so a
binding can subscribe per tile and re-render only the tiles whose cell actually changed during a
drag. In React that’s useSyncExternalStore; another framework plugs in its own reactivity.
That split is the whole trick: the engine produces layout, the controller carries it, the binding renders it — and only the last step is framework-specific.
Collision: the innermost grid wins
Each grid registers a per-droppable collision detector whose priority is base + depth, where depth
counts the grid’s ancestor grid containers in the DOM. The deepest grid under the pointer wins, so
a drag that starts inside a nested grid stays scoped to it until you drag a tile out — ground truth
from DOM nesting, independent of the React tree.
Going lower: driving a grid with the engine
Advanced — binding-author surface. Everything below is the @snapgridjs/dnd API. It’s
semi-stable while the first non-React bindings shape it, and @snapgridjs/react is the reference
implementation. You never need this to use snapgrid.
A binding does four things. In sketch (the shape, not a runnable app):
import { attachEngine, GridController, registerController } from "@snapgridjs/dnd";
// 1. One engine per dnd-kit manager (ref-counted; detach on teardown).
const detach = attachEngine(manager);
// 2. A controller per grid — the observable render bridge.
const controller = new GridController(gridId, layout, manager);
registerController(manager, gridId, controller);
// 3. Publish this grid's geometry, compaction, gates, and callbacks (each update).
controller.setConfig({ positionParams, gridConfig, compactor, /* gates, callbacks, … */ });
controller.setCommitted(layout);
// 4. Render from the controller: subscribe, read snapshots, position tiles by cell.
const unsubscribe = controller.subscribe(rerender);
const rendered = controller.renderedSnapshot(); // preview while dragging, else committed
// …and each tile is a dnd-kit sortable carrying { snapGrid: { kind, itemId, item, group } }.That is exactly what useGridContainer and useGridItem do, wrapped in React’s reactivity — a
Vue/Solid binding is the same four steps in that framework’s idioms. For the full, working version,
read the @snapgridjs/react source.
A dedicated build-a-binding guide will land once the surface stabilizes.