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 →
VueDocumentationGetting Started

Getting Started

snapgrid is a draggable, resizable, repacking grid layout for Vue 3 — a react-grid-layout  v2 alternative built on dnd-kit . It keeps the controlled { i, x, y, w, h } model RGL users know, and adds things RGL can’t do: dragging tiles between grids, nested grids, interop with the wider dnd-kit ecosystem (sortable lists, boards), keyboard accessibility, and pluggable packing — masonry, gravity, shelf, or your own compactor.

It’s headless-first — you wire composables to your own markup — with a turnkey <GridLayout> for when you just want a grid that works. Requires Vue 3.5+.

Drag & resize
drag a tile · resize from the corner

Know dnd-kit? You already know most of this. snapgrid is dnd-kit plus grid math — a grid lives in dnd-kit’s <DragDropProvider>, tiles are draggables, the surface is a droppable. If you’ve touched useDraggable / useSortable, the headless API will feel familiar. New to dnd-kit? A five-minute skim of its overview  is the single easiest win before you read on.

Install

pnpm add @snapgridjs/vue @dnd-kit/vue @dnd-kit/dom

@dnd-kit/vue and @dnd-kit/dom are peer dependencies — snapgrid uses your copy, so there’s nothing to dedupe. See Installation for the full breakdown.

Your first grid

Hold the layout in state

snapgrid is controlled: you own the array, it never mutates it. Each item has an id (i), a position (x, y), and a size (w, h) in grid units — columns and rows, not pixels. Hold it in a ref.

<script setup> import { ref } from "vue"; const layout = ref([ { i: "a", x: 0, y: 0, w: 4, h: 2 }, { i: "b", x: 4, y: 0, w: 4, h: 2 }, { i: "c", x: 8, y: 0, w: 4, h: 2 }, ]); </script>

Measure the container width

The grid turns columns into pixels using the container’s width. useContainerWidth measures it with a ResizeObserver; bind its setRef function ref to the element whose width should drive the grid. Always destructure what a composable returns — Vue only auto-unwraps top-level setup bindings, so a kept-whole handle like container.style would reach the template as a ref object rather than a string.

<script setup> import { useContainerWidth } from "@snapgridjs/vue"; // Composables return refs — destructure them so the template auto-unwraps each one. const { width, setRef } = useContainerWidth(); </script> <template> <div :ref="setRef"></div> </template>

Host the grid inside a dnd-kit provider

Render inside dnd-kit’s <DragDropProvider>, and let useGridContainer host the grid. Pass it a getter for its options; it returns a setRef function ref + style for your surface element and the grid’s group — its id, which ties tiles to it.

<!-- Surface.vue --> <script setup> import { useGridContainer } from "@snapgridjs/vue"; import Tile from "./Tile.vue"; const props = defineProps({ layout: Array, width: Number }); const emit = defineEmits(["layoutChange"]); const { setRef, style, group } = useGridContainer(() => ({ layout: props.layout, width: props.width, onLayoutChange: (next) => emit("layoutChange", next), })); </script> <template> <div :ref="setRef" :style="style"> <Tile v-for="it in layout" :key="it.i" :id="it.i" :group="group" /> </div> </template>

useGridContainer must run inside the <DragDropProvider>. Vue resolves inject from the parent chain, so a component can’t see a provider it renders itself — the grid host has to be a child of it, like Surface above. Call it in the component that renders the provider and you’ll get no grid found for group. This is why the grid host lives in its own component.

Render a tile with useGridItem

Each tile calls useGridItem({ id, group }) — plain values, not a getter — for its setRef function ref and positioning style, the way a dnd-kit sortable item declares its list. You bring the element, classes, and content.

<!-- Tile.vue --> <script setup> import { useGridItem } from "@snapgridjs/vue"; const props = defineProps({ id: String, group: String }); const { setRef, style } = useGridItem({ id: props.id, group: props.group }); </script> <template> <div :ref="setRef" :style="style" class="tile">{{ id }}</div> </template>

Mark the landing spot

useGridPlaceholder(group) tells you where the dragged tile will land (it’s null when idle). Render a marker with its style inside the surface, alongside the tiles, so the target cell shows as you drag.

<script setup> import { useGridPlaceholder } from "@snapgridjs/vue"; // inside Surface — after useGridContainer. `group` is a ref, and the composable // takes the plain id: const placeholder = useGridPlaceholder(group.value); </script> <template> <!-- inside the container — after the tiles: --> <div v-if="placeholder" class="placeholder" :style="placeholder.style"></div> </template>

That’s it — drag it

There’s nothing more to wire. While you drag with a pointer, the tile floats itself above everything — dnd-kit lifts it into the browser’s top layer, so it can cross grids unclipped — and on drop it settles into its new cell. Keyboard drags step the tile cell-by-cell in place. Tiles drag, the rest compact upward, and every committed change flows back through onLayoutChange.

Putting it together

Board.vue
<script setup> import { ref } from "vue"; import { DragDropProvider, useContainerWidth } from "@snapgridjs/vue"; import Surface from "./Surface.vue"; const { width, setRef } = useContainerWidth(); const layout = ref([ { i: "a", x: 0, y: 0, w: 4, h: 2 }, { i: "b", x: 4, y: 0, w: 4, h: 2 }, { i: "c", x: 8, y: 0, w: 4, h: 2 }, ]); </script> <template> <!-- DragDropProvider is the outermost element; Surface (which calls useGridContainer) lives inside it. --> <div :ref="setRef"> <DragDropProvider> <Surface :layout="layout" :width="width" @layout-change="layout = $event" /> </DragDropProvider> </div> </template>
Surface.vue
<script setup> import { useGridContainer, useGridPlaceholder } from "@snapgridjs/vue"; import Tile from "./Tile.vue"; const props = defineProps({ layout: Array, width: Number }); const emit = defineEmits(["layoutChange"]); const { setRef, style, group } = useGridContainer(() => ({ layout: props.layout, width: props.width, onLayoutChange: (next) => emit("layoutChange", next), gridConfig: { cols: 12, rowHeight: 80, margin: [12, 12] }, })); const placeholder = useGridPlaceholder(group.value); </script> <template> <div :ref="setRef" :style="style"> <Tile v-for="it in layout" :key="it.i" :id="it.i" :group="group" /> <div v-if="placeholder" class="placeholder" :style="placeholder.style"></div> </div> </template>
Tile.vue
<script setup> import { useGridItem } from "@snapgridjs/vue"; const props = defineProps({ id: String, group: String }); const { setRef, style } = useGridItem({ id: props.id, group: props.group }); </script> <template> <div :ref="setRef" :style="style" class="tile">{{ id }}</div> </template>

snapgrid ships no CSS. The tiles and the .placeholder marker are styled by your own classes — see Styling for the hooks it exposes. Want resize handles? Tiles are drag-only until you render them — see Resizing.

Don’t want to wire all that?

The turnkey <GridLayout> bundles the provider, the host, the tiles, and the placeholder. Pass it layout / width / onLayoutChange and an item scoped slot — done. It’s the gentlest on-ramp; drop to the composables for any grid that needs custom markup. Same engine either way.

<GridLayout :layout="layout" :width="width" :on-layout-change="(next) => (layout = next)"> <template #item="{ item }"> <div class="tile">{{ item.i }}</div> </template> </GridLayout>

Where to next

Last updated on