Implementing a Custom Grid Drag‑and‑Drop Layout with Vue
This article explains how to build a Vue‑based, 6‑column by 4‑row grid layout where components can be dragged from a material bar and dropped onto grid cells, covering drag‑and‑drop API usage, grid sizing, collision detection, styling during drag, and secondary dragging support.
Overview
A requirement arose to create a custom homepage layout that divides the screen into a 6‑column by 4‑row grid, allowing components to be dragged from a toolbox and rendered in the appropriate cells.
Key Concepts
Drag‑and‑Drop API – see MDN for detailed documentation.
CSS Grid – a two‑dimensional layout system more powerful than Flexbox; see MDN for grid layout.
Features to Implement
Drag component from material bar to layout container.
Grid container layout.
Overlap detection when placing.
Styling during drag.
Styling after placement.
Secondary dragging of placed items.
Drag‑and‑Drop Implementation
The drag events used are dragstart , drag , and dragend for the draggable element, and dragenter , dragleave , dragover , and drop for the drop zone.
Draggable Element
Make an element draggable by adding draggable="true" . Store drag data in a custom dragStore during dragstart and clear it on dragend .
<script setup lang="ts">
import { dragStore } from "./drag";
const props = defineProps<{ data: DragItem; groupName?: string }>();
const onDragstart = (e) => dragStore.set(props.groupName, { ...props.data });
const onDragend = () => dragStore.remove(props.groupName);
</script>
<template>
<div class="drag-item__el" draggable="true" @dragstart="onDragstart" @dragend="onDragend"></div>
</template>The DragStore class manages a map of drag data keyed by a group name.
class DragStore<T extends DragItemData> {
moveItem = new Map<string, DragItemData>();
set(key: string, data: T) { this.moveItem.set(key, data); }
remove(key: string) { this.moveItem.delete(key); }
get(key: string): undefined | DragItemData { return this.moveItem.get(key); }
}Drop Area
Mark a container as a drop target by listening to dragenter , dragleave , dragover and calling event.preventDefault() to allow dropping.
<script setup lang="ts">
const onDragenter = (e) => { e.preventDefault(); };
const onDragover = (e) => { e.preventDefault(); };
const onDragleave = (e) => { e.preventDefault(); };
</script>
<template>
<div @dragenter="onDragenter($event)" @dragover="onDragover($event)" @dragleave="onDragleave($event)" @drop="onDrop($event)"></div>
</template>After this code, the element becomes draggable and shows a drop cursor when over the container.
Grid Layout
The container is divided into grid cells using CSS Grid. The size of each cell is calculated with @vueuse/core 's useElementSize hook (or a ResizeObserver ).
import { useElementSize } from "@vueuse/core";
/**
* Compute box size for a grid container.
*/
export const useBoxSize = (target, column, row, gap) => {
const { width, height } = useElementSize(target);
return computed(() => ({
width: (width.value - (column - 1) * gap) / column,
height: (height.value - (row - 1) * gap) / row,
}));
};Grid cells are rendered with a double v‑for loop:
<div class="drop-content__drop-container" @dragenter="onDragenter" @dragover="onDragover" @dragleave="onDragleave" @drop="onDrop">
<template v‑for="x in rowCount">
<div class="bg‑column" v‑for="y in columnCount" :key="`${x}-${y}`"></div>
</template>
</div>The CSS uses grid-template-columns and grid-template-rows with repeat to create equal cells, and disables pointer events on the cells so they don’t block drag events.
.drop-content__drop-container {
display: grid;
row-gap: v‑bind("gap+'px'");
column-gap: v‑bind("gap+'px'");
grid-template-columns: repeat(v‑bind("columnCount"), v‑bind("boxSize.width+'px'"));
grid-template-rows: repeat(v‑bind("rowCount"), v‑bind("boxSize.height+'px'"));
.bg‑column { background: #fff; border-radius: 6px; pointer-events: none; }
}Placing Elements
When an element is dragged into the container, its mouse offset ( offsetX , offsetY ) is used to compute the grid coordinates.
// Compute grid X coordinate
const getX = (num) => parseInt(num / (boxSizeWidth + gap));
// Compute grid Y coordinate
const getY = (num) => parseInt(num / (boxSizeHeight + gap));The current drag state is stored in a reactive object:
const current = reactive({
show: false,
id: undefined,
column: 0,
row: 0,
x: 0,
y: 0,
});
const onDragenter = (e) => {
e.preventDefault();
const dragData = dragStore.get(props.groupName);
if (dragData) {
current.column = dragData.column;
current.row = dragData.row;
current.x = getX(e.offsetX);
current.y = getY(e.offsetY);
current.show = true;
}
};
const onDragover = (e) => {
e.preventDefault();
const dragData = dragStore.get(props.groupName);
if (dragData) {
current.x = getX(e.offsetX);
current.y = getY(e.offsetY);
}
};
const onDragleave = (e) => {
e.preventDefault();
current.show = false;
current.id = undefined;
};On drop , the element data is pushed into a list that stores all placed items.
const list = ref([]);
const onDrop = async (e) => {
e.preventDefault();
current.show = false;
const item = dragStore.get(props.groupName);
list.value.push({ ...item, x: current.x, y: current.y, id: new Date().getTime() });
};Collision Detection
Two helper functions determine whether a rectangle is inside the container and whether it intersects other placed items.
export const booleanWithin = (p1, p2) => {
return p1[0] <= p2[0] && p1[1] <= p2[1] && p1[2] >= p2[2] && p1[3] >= p2[3];
};
export const booleanIntersects = (p1, p2) => {
return !(p1[2] <= p2[0] || p2[2] <= p1[0] || p1[3] <= p2[1] || p2[3] <= p1[1]);
};A computed property isPutDown uses these helpers to decide if the current placement is valid.
const isPutDown = computed(() => {
const currentXy = [current.x, current.y, current.x + current.column, current.y + current.row];
return (
booleanWithin([0, 0, columnCount.value, rowCount.value], currentXy) &&
list.value.every(item => item.id === current.id || !booleanIntersects([item.x, item.y, item.x + item.column, item.y + item.row], currentXy))
);
});Styling During Drag
The preview element uses grid-area to show where the item would be placed. The coordinates are offset by +1 because CSS grid is 1‑based.
grid-area: `${y + 1} / ${x + 1} / ${y + row + 1} / ${x + column + 1}`;Preview Container
A separate preview container mirrors the drop container’s grid but has pointer-events: none so it does not block drag events.
.drop-content__preview,
.drop-content__drop-container { /* shared styles */ }Secondary Dragging
Placed items can be dragged again. They receive draggable="true" and the same drag handlers. When an existing item is dropped, only its coordinates are updated.
if (item.id) {
item.x = current.x;
item.y = current.y;
} else {
list.value.push({ ...item, x: current.x, y: current.y, id: new Date().getTime() });
}Offset Optimization
To avoid visual offset when dragging an already placed element, the initial mouse offset inside the element is stored and subtracted from subsequent calculations.
const onDragstart = (e) => {
const data = props.data;
data.offsetX = e.offsetX;
data.offsetY = e.offsetY;
dragStore.set(props.groupName, data);
};
current.x = getX(e.offsetX) - getX(dragData?.offsetX ?? 0);
current.y = getY(e.offsetY) - getY(dragData?.offsetY ?? 0);Drag‑Element Optimization
During secondary dragging, other preview elements are set to pointer-events: none to prevent them from blocking the drop zone, and the dragged element can be temporarily moved out of the container with a large transform translation.
:style="{ pointerEvents: current.show && item.id !== current.id ? 'none' : 'all' }"
moveing.value ? { opacity: 0, transform: `translate(-999999999px, -9999999999px)` } : {};Conclusion
The guide demonstrates a functional grid drag‑and‑drop layout that satisfies basic business needs. Additional features such as resizing, automatic collision resolution, or advanced interactions can be built on top of this foundation.
Full source code and live demo are available at https://stackblitz.com/edit/vitejs-vite-rkwugn?file=README.md .
Rare Earth Juejin Tech Community
Juejin, a tech community that helps developers grow.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.