Implementing a Drag-and-Drop Interface with Vue Draggable

The draggable component defines a source area from which elements can be dragged.

<draggable
    v-if="sourcePanelActive"
    :list="availableItems"
    :group="{ name: 'dragGroup', pull: 'clone', put: false }"
    :sort="false"
    :move="validateDragOperation"
    @end="handleDragEnd"
    class="source-panel"
>
    <div v-for="item in availableItems" :key="item.id" class="item-card">
        <img :src="resolveItemImage(item)" :alt="item.title" />
        <p>{{ item.label }}</p>
    </div>
    <div v-if="availableItems.length === 0" class="empty-state">
        <img src="@/assets/no-data.png" alt="No data" />
        <p>No items available</p>
    </div>
</draggable>

Validation and event handling functions for the drag oeprations.

validateDragOperation(event) {
    const draggedElement = event.draggedContext.element;
    const isAlreadySelected = this.selectedItems.some(item => item.id === draggedElement.id);
    if (isAlreadySelected) {
        return false;
    }
},

handleDragEnd(event) {
    const insertionIndex = event.newIndex;
    if (this.selectedItems.length > this.maxItems) {
        if (this.selectedItems[insertionIndex + 1]?.id) {
            this.selectedItems.splice(insertionIndex, 1);
        } else {
            this.selectedItems.splice(insertionIndex + 1, 1);
        }
    }
    this.layoutArray = [...this.selectedItems];
}

The drop target area, including temlpate management UI.

<div class="template-editor">
    <div class="editor-header">Template Builder</div>
    <el-scrollbar>
        <div class="editor-content">
            <div class="layout-slot" v-for="(slot, idx) in layoutArray" :key="idx">
                <span class="slot-index">{{ idx + 1 }}</span>
                <div v-if="!slot.id" class="empty-slot">
                    <i class="el-icon-plus"></i>
                </div>
                <div v-else class="filled-slot" @click="selectSlot(slot)">
                    <img :src="resolveItemImage(slot)" :alt="slot.label" />
                    <p>{{ slot.label }}</p>
                </div>
                <span 
                    v-if="slot.id" 
                    class="remove-btn" 
                    @click="removeItem(slot, idx)"
                >
                    <i class="el-icon-minus"></i>
                </span>
            </div>

            <div class="drop-layer">
                <draggable :list="selectedItems" :group="{ name: 'dragGroup' }">
                    <div 
                        class="drop-slot" 
                        v-for="(item, idx) in selectedItems" 
                        :key="idx"
                    >
                        <div v-if="!item.id" class="drop-empty"></div>
                        <div v-else class="drop-filled"></div>
                        <span 
                            v-if="item.id" 
                            class="invisible-remove" 
                            @click="removeItem(item, idx)"
                        ></span>
                    </div>
                </draggable>
            </div>
        </div>
    </el-scrollbar>

    <div class="editor-actions">
        <div class="actions-container">
            <el-popover 
                placement="top-start" 
                width="280" 
                trigger="click"
                popper-class="template-popover"
            >
                <el-scrollbar>
                    <div class="template-list">
                        <div 
                            class="template-option" 
                            :class="{ 'active': activeTemplateId === template.id }"
                            v-for="template in savedTemplates"
                            :key="template.id"
                        >
                            <div 
                                class="template-name" 
                                @click="loadTemplate(template)"
                            >
                                {{ template.name }}
                            </div>
                            <i 
                                class="el-icon-delete" 
                                @click.stop="deleteTemplate(template.id)"
                            ></i>
                        </div>
                        <div 
                            v-if="savedTemplates.length === 0" 
                            class="no-templates"
                        >
                            No templates saved
                        </div>
                    </div>
                </el-scrollbar>
                <div class="action-btn" slot="reference">Select</div>
            </el-popover>

            <div class="action-btn primary" @click="openSaveDialog">Save</div>
            <div class="action-btn primary" @click="applyTemplate">Apply</div>
        </div>
    </div>

    <el-dialog 
        title="Save Template" 
        :visible.sync="saveDialogVisible" 
        width="30%"
    >
        <el-form 
            :model="templateForm" 
            :rules="validationRules" 
            ref="templateFormRef"
        >
            <el-form-item label="Template Name" prop="name">
                <el-input 
                    v-model="templateForm.name" 
                    placeholder="Enter 3-10 characters"
                ></el-input>
            </el-form-item>
        </el-form>
        <span slot="footer">
            <el-button @click="saveDialogVisible = false">Cancel</el-button>
            <el-button type="primary" @click="saveCurrentTemplate">Confirm</el-button>
        </span>
    </el-dialog>
</div>

The drop layer is positioned absolutely over the main layout area. This techinque prevents visual shifting of adjacent elements during drag operations, creating a consistent grid.

Import and register the draggable component.

import draggable from 'vuedraggable';

export default {
    components: {
        draggable
    },
    // ... other component options
}

Styling for the template editor interface.

.template-editor {
    width: 400px;
    background: #2d2d2d;
    height: 100%;

    .editor-header {
        height: 60px;
        border-bottom: 1px solid #fff;
        display: flex;
        align-items: center;
        justify-content: center;
        color: #fff;
        font-size: 16px;
    }

    ::v-deep .el-scrollbar {
        .el-scrollbar__wrap {
            overflow-x: hidden;
        }
    }

    .editor-content {
        width: 100%;
        height: calc(100vh - 277px);
        padding-top: 30px;
        box-sizing: border-box;
        position: relative;

        .layout-slot {
            width: 100%;
            height: 50px;
            margin-bottom: 30px;
            display: flex;
            align-items: center;
            position: relative;

            .slot-index {
                color: #fff;
                width: 10px;
                margin-left: 10px;
                display: block;
            }

            .empty-slot {
                background: #2d2d2d;
                border: 1px solid #999;
                border-radius: 4px;
                display: flex;
                align-items: center;
                justify-content: center;
                color: #999;
                width: 300px;
                height: 100%;
                margin-left: 20px;
                box-sizing: border-box;
            }

            .filled-slot {
                width: 300px;
                background: #424242;
                border-radius: 4px;
                display: flex;
                align-items: center;
                padding: 10px;
                color: #fff;
                margin-left: 20px;
                height: 100%;
                box-sizing: border-box;
                cursor: pointer;

                img {
                    width: 30px;
                    height: 30px;
                }

                p {
                    width: 260px;
                    white-space: nowrap;
                    overflow: hidden;
                    text-overflow: ellipsis;
                }
            }

            .remove-btn {
                display: block;
                width: 20px;
                height: 20px;
                padding: 5px;
                box-sizing: border-box;
                border-radius: 50%;
                background-color: #f00;
                display: flex;
                align-items: center;
                justify-content: center;
                margin-left: 14px;
                cursor: pointer;

                i {
                    color: #fff;
                }
            }
        }
    }

    .drop-layer {
        position: absolute;
        background-color: transparent;
        top: 0;
        left: 0;
        padding-top: 30px;
        width: 340px;
        padding-left: 20px;
        box-sizing: border-box;

        .drop-slot {
            width: 360px;
            background-color: transparent;

            .drop-filled, .drop-empty {
                width: 303px;
                background-color: transparent;
                border: none;
            }
        }
    }

    .editor-actions {
        width: 340px;
        height: 80px;
        background: #383838;
        position: fixed;
        bottom: 0;
        left: 0;

        .actions-container {
            width: 100%;
            height: 100%;
            display: flex;
            align-items: center;
            justify-content: space-evenly;

            .action-btn {
                width: 72px;
                height: 30px;
                background: #383838;
                border: 1px solid #006eff;
                border-radius: 4px;
                display: flex;
                align-items: center;
                justify-content: center;
                color: #006eff;
                cursor: pointer;

                &.primary {
                    background: #006eff;
                    color: #fff;
                }
            }
        }
    }
}

Tags: Vue.js Drag and Drop Vue Draggable UI Components Frontend Development

Posted on Fri, 12 Jun 2026 18:31:09 +0000 by JasonTC