Nested grids
A grid can live inside a tile of another grid. Nested grids share the outer grid’s
DragDropProvider (one dnd-kit manager), so a tile can be dragged between the inner and outer
grids. snapgrid resolves which grid the pointer is over with an innermost-wins rule: a drag that
starts inside the inner grid stays scoped to it, and dragging a tile out hands it to the outer grid.
Nested grids cross levels by default. To keep a sub-grid self-contained — tiles can’t leave it — see Contain a nested grid below.
The one thing to wire: the outer tile that hosts the inner grid must drag only from a handle, so a
pointer-down inside the inner grid doesn’t drag the whole panel. Put a handleRef (from
useGridItem) on the panel’s header — not the grid-wide dragConfig.handle, which would force
every outer tile to drag from that selector and disable the ones without it.
import { useGridContainer, useGridItem } from "@snapgridjs/react";
// One provider at the top (yours, or supplied by <GridLayout> / <SnapGridGroup>).
// The inner grid does NOT mount its own — it shares this one.
function OuterBoard({ outer, width, setOuter }) {
const { containerProps, group } = useGridContainer({ layout: outer, width, onLayoutChange: setOuter });
return (
<div {...containerProps}>
{outer.map((it) => (
<Tile key={it.i} id={it.i} group={group} />
))}
</div>
);
}
function Tile({ id, group }) {
const { ref, handleRef, style } = useGridItem({ id, group });
if (id === "panel") {
return (
<div ref={ref} style={style} className="panel">
{/* The header is the only drag handle, so grabbing an inner tile never
drags the panel — and the other outer tiles stay whole-tile draggable. */}
<div ref={handleRef} className="panel-head">Nested board</div>
<InnerBoard /> {/* an ordinary host — shares the provider above, no new one */}
</div>
);
}
return (
<div ref={ref} style={style} className="tile">
{id}
</div>
);
}
// InnerBoard is an ordinary useGridContainer host. It just does NOT wrap itself
// in a <DragDropProvider>, so it joins the outer grid's manager.How it works
The grids share one dnd-kit manager, so a single collision pass sees both droppable surfaces. When
their rects overlap (the pointer is over the inner grid, which sits inside the outer), snapgrid ranks
the innermost grid highest, so the drag resolves to the inner grid. Move the pointer out of the
inner grid and the outer grid wins — that’s how a tile crosses levels. The panel’s handleRef keeps a
pointer-down on an inner tile from arming the panel’s own drag.
A tile that crosses levels is removed from one grid’s layout and added to the other’s — wire both
grids’ onLayoutChange, exactly like cross-grid dragging between siblings.
Nested drop zones (a non-grid droppable inside a grid)
The same innermost-wins rule extends to your own drop targets. Put a plain dnd-kit useDroppable —
a trash slot, an “archive” panel, a sub-list — inside a grid tile and, by default, it can never win: a
grid claims a drag with a boosted collision priority, so the grid underneath always outranks a plain
droppable sitting on top of it. To opt your drop zone into the grid’s depth ranking:
- Pass
nestedDropCollisionDetectorto youruseDroppable. This alone makes the zone outrank the grid it sits inside — the grid is a marked ancestor, so the zone ranks one level deeper. - Mark the element with
data-snapgrid-droppable(or the exportedSNAPGRID_DROPPABLE_ATTR). This is only needed if you nest another droppable inside this zone, so that inner target counts this boundary and outranks it in turn. It’s harmless and future-proofs nesting, so the demo below sets it.
import {
useDroppable,
nestedDropCollisionDetector,
SNAPGRID_DROPPABLE_ATTR,
} from "@snapgridjs/react";
function ArchiveZone() {
const { ref, isDropTarget } = useDroppable({
id: "archive",
accept: (source) => source.type === "grid-item",
collisionDetector: nestedDropCollisionDetector,
});
// The detector already ranks this zone above its grid. The marker makes this a
// nesting boundary — only matters for droppables you nest INSIDE the zone.
return (
<div ref={ref} {...{ [SNAPGRID_DROPPABLE_ATTR]: "" }} data-drop-target={isDropTarget || undefined}>
Archive
</div>
);
}When the pointer is over the zone, the zone wins the collision: the grid backs off — it shows no
placeholder and leaves its layout untouched (its engine treats a target it doesn’t own as a revert, so
the dragged tile stays put) — and your zone becomes the resolved target. Handle the drop yourself on the
shared DragDropProvider:
<DragDropProvider
onDragEnd={(event) => {
const { source, target } = event.operation;
if (target?.id === "archive" && source?.type === "grid-item") {
// your drop logic — the grid already reverted the tile
}
}}
>Depth counts every marked boundary, not just grids. A drop zone nested inside another marked drop zone outranks its parent, so arbitrary nesting resolves innermost-first — the same rule that ranks an inner grid above its outer one. Mark each intermediate zone: two zones at the same depth tie.
Contain a nested grid (opt out of cross-layer)
To keep a nested grid’s tiles from leaving it — a self-contained widget — give it its own
<DragDropProvider>. That puts it on a separate manager, so the outer grid’s collision never sees it
and tiles can’t cross the boundary:
import { DragDropProvider } from "@dnd-kit/react";
<div className="panel">
<div ref={handleRef} className="panel-head">Contained board</div>
<DragDropProvider>
<InnerBoard /> {/* its own manager → isolated; tiles stay put */}
</DragDropProvider>
</div>;Limits
Host the inner grid with a per-tile handleRef, not a grid-wide dragConfig.handle. The latter is
applied to every tile in the grid, so any tile without a matching handle element becomes
undraggable.
- Size the inner grid to its cell. It measures its own container with
useContainerWidth, so it reflows as the outer tile is resized.