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 another framework’s binding (or a direct dnd-kit integration) would build on.
You don’t need any of this to use snapgrid. <GridLayout> and the headless
composables 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/vue the binding — composables + components that register dnd-kit
entities and render from the controller. (depends on @dnd-kit/vue)The line that matters is the middle one. The engine has no framework dependency, so each binding
reuses core + dnd unchanged and only re-implements the thin top layer — registering entities and
rendering. @snapgridjs/vue is one such binding (@snapgridjs/react is another): both share the
exact same drag brain. That’s why it 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 (dnd-kit interop), and what
makes cross-grid and external drops fall out of dnd-kit’s own collision system rather than bespoke code.
Composables run in a child of the provider
Vue resolves inject from the parent chain, so a component can never see a provider it renders
itself. Every snapgrid composable — useGridContainer, useGridItem, useGridPlaceholder,
useGridResizeHandle — reaches the dnd-kit manager through inject, so each one must be called in a
child component of <DragDropProvider>, never in the component that renders it.
<!-- Board.vue — renders the provider, so it must NOT call useGridContainer itself. -->
<script setup>
import { ref } from "vue";
import { DragDropProvider } from "@snapgridjs/vue";
import Surface from "./Surface.vue";
const layout = ref([{ i: "a", x: 0, y: 0, w: 4, h: 2 }]);
</script>
<template>
<DragDropProvider>
<Surface :layout="layout" :width="600" @layout-change="layout = $event" />
</DragDropProvider>
</template>Call useGridContainer in the same component that renders <DragDropProvider> and the composable
simply finds no manager: the grid never registers as a droppable, nothing drags, and tiles rendered
under it throw snapgrid: no grid found for group "…". One extra child component is the fix.
The turnkey components already obey the rule internally: <GridLayout> renders the provider and its
surface as a separate child component, which is where useGridContainer actually runs. Reach for
<GridLayout> / <SnapGridGroup> and you never have to think about it.
One engine per manager
The drag brain isn’t in the binding — 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 Vue the binding bridges it with a shallowRef tick bumped from controller.subscribe:
read that tick inside a computed and the computed re-runs on every drag-state emit. Since
setConfig / setCommitted deliberately don’t emit, each grid also publishes a second version tick
from its container, so a tile’s computed position depends on both — drag state and config/layout
republishes. React plugs in useSyncExternalStore instead; 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 Vue component 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 early bindings shape it; @snapgridjs/react and @snapgridjs/vue are the
reference implementations. 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 Vue’s reactivity — a
React/Svelte/Solid binding is the same four steps in that framework’s idioms. For the full, working version,
read the @snapgridjs/vue source.
A dedicated build-a-binding guide will land once the surface stabilizes.