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 →
VueDocumentationGuidesArchitecture & dnd-kit

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 primitiveits role in a grid
<DragDropProvider>the grid lives inside it — you supply it
useSortableevery tile (wrapped by useGridItem)
useDroppablethe grid surface (created by useGridContainer)
collision detectionresolves which grid the pointer is over — the drop target
Feedback pluginfloats 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:

  1. resolves the source grid from the drag payload’s group, and the destination grid from the collision target;
  2. computes the next layout with the pure session functions from corebeginDragdragTocommitLayout for a move, or beginReceive for a tile arriving from another grid;
  3. 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.

Last updated on