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 →
ReactDocumentationGuidesNested grids

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.

Nested grids
drag the panel by its header; drag tiles between the inner and outer grids

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:

  1. Pass nestedDropCollisionDetector to your useDroppable. This alone makes the zone outrank the grid it sits inside — the grid is a marked ancestor, so the zone ranks one level deeper.
  2. Mark the element with data-snapgrid-droppable (or the exported SNAPGRID_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.
Drop zone inside a grid
drag a tile onto the Archive panel — the nested drop zone wins over the grid
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.
Last updated on