Trello-style drag and drop using vue-smooth-dnd

Image for post
Image for post

Drag and drop is one of the most useful features in any sort of application.

Be it a task-list, note-taking, todo-list or a project management tool like Trello, drag and drop is a fantastic feature that makes things quite seamless.

I was always fascinated with Trello’s UI, and even made an attempt to clone it using plain HTML & CSS without any interactions. I knew the next step was to learn drag and drop.

After a lot of Googling, I came across vue-smooth-dnd, which is an amazing plugin to use.

What We Will Do

To make our functionality similar to Trello, we will create a wrapper container, four vertical list containers and 2 cards in each list container, for now.
To keep the UI simple, I am using plain CSS + flex.

Pre-requisites

  1. vue.js installed with vue-cli
  2. vue-smooth-dnd

Functionality

The core functionality that we are focusing is not just simple drag and drop, but to also style & animate the transitions similar to Trello.

Let’s Get Started

For now, we need three main files;

  1. App.vue
  2. Card.vue
  3. CardList.vue

Lets install vue-smooth-dnd first, by running the command;

npm i vue-smooth-dnd

For yarn users;

yarn add vue-smooth-dnd

Once that’s done, lets move on to our App.vue file.

App.vue

Delete all the default code from your App.vue and replace it with the following below;

<template>
<div id="app">
<CardList />
<CardList />
<CardList />
</div>
</template>
<script>
import CardList from "./components/CardList.vue";
export default {
name: "App",
components: {
CardList
}
};
</script>
<style>
body {
background: #f6f9fc;
}
#app {
display: flex;
justify-content: space-evenly;
align-items: center;
font-family: "Roboto", sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
:root {
font-size: 10px;
}
</style>

Our App.vue component comprises of the CardList.vue component, wherein we have repeated it multiple times to display multiple vertical lists. The root font-size is set as 10px so that using rem is easy. I’ve also used the Roboto font to make things look clean.

Here’s the index.html file for reference;

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
<link rel="icon" href="<%= BASE_URL %>favicon.ico" />
<link
href="https://fonts.googleapis.com/css2?family=Roboto&display=swap"
rel="stylesheet"
/>
<title>Trello DnD</title>
</head>
<body>
<noscript>
<strong
>We're sorry but Trello DnD doesn't work properly without JavaScript
enabled. Please enable it to continue.</strong
>
</noscript>
<div id="app"></div>
<!-- built files will be auto injected -->
</body>
</html>

Next up, create CardList.vue in the components folder.

Our CardList.vue component will comprise a vertical list, wherein we use Card.vue as our child component.

CardList.vue

<template>
<div class="card-list-container">
<Card />
<Card />
</div>
</template>
<script>
import Card from "./Card";
export default {
name: "CardList",
components: {
Card
}
};
</script>
<style scoped>
.card-list-container {
display: flex;
flex-direction: column;
width: 18%;
max-width: 18%;
flex: 0 0 18%;
border: 1px solid #dcebf4;
border-radius: 6px;
padding: 1rem 1rem 0 1rem;
margin-top: 5rem;
}
</style>

Then, create Card.vue in the components folder.
Similar to App.vue, we have declared the Card component multiple times.

Card.vue

<template>
<div class="card-container">
<div class="card-body">
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod
tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam.
</p>
</div>
</div>
</template>
<script>
export default {
name: "Card"
};
</script>
<style scoped>
.card-container {
background: #fff;
box-shadow: 0px 2px 10px rgba(0, 0, 0, 0.15);
width: 100%;
height: 100%;
cursor: pointer;
margin-bottom: 2rem;
}
.card-body {
padding: 1rem 2rem;
}
p {
font-size: 1.6rem;
}
</style>

Alright, now comes the fun part.

Lets import our Container & Draggable components from the vue-smooth-dnd plugin, and utilize them, after which our CardList.vue looks like this;

<template>
<div class="card-list-container">
<Container
:get-child-payload="getChildPayload1"
group-name="1"
@drop="onDrop('listOne', $event)"
>
<Draggable v-for="(item, $index) in listOne" :key="$index">
<Card :item="item" />
</Draggable>
</Container>
<Container
:get-child-payload="getChildPayload2"
group-name="1"
@drop="onDrop('listTwo', $event)"
>
<Draggable v-for="(item, $index) in listTwo" :key="$index">
<Card :item="item" />
</Draggable>
</Container>
<Container
:get-child-payload="getChildPayload3"
group-name="1"
@drop="onDrop('listThree', $event)"
>
<Draggable v-for="(item, $index) in listThree" :key="$index">
<Card :item="item" />
</Draggable>
</Container>
<Container
:get-child-payload="getChildPayload4"
group-name="1"
@drop="onDrop('listFour', $event)"
>
<Draggable v-for="(item, $index) in listFour" :key="$index">
<Card :item="item" />
</Draggable>
</Container>
</div>
</template>
<script>
import { Container, Draggable } from "vue-smooth-dnd";
import { applyDrag } from "../utils/applyDrag";
import Card from "./Card";
export default {
name: "CardList",
components: {
Card,
Container,
Draggable
},
data() {
return {
listOne: [
{
id: 0,
text: `List 1 Text 0`
},
{
id: 1,
text: `List 1 Text 1`
},
{
id: 2,
text: `List 1 Text 2`
},
{
id: 3,
text: `List 1 Text 3`
}
],
listTwo: [
{
id: 0,
text: `List 2 Text 0`
},
{
id: 1,
text: `List 2 Text 1`
},
{
id: 2,
text: `List 2 Text 2`
},
{
id: 3,
text: `List 2 Text 3`
}
],
listThree: [
{
id: 0,
text: `List 3 Text 0`
},
{
id: 1,
text: `List 3 Text 1`
},
{
id: 2,
text: `List 3 Text 2`
},
{
id: 3,
text: `List 3 Text 3`
}
],
listFour: [
{
id: 0,
text: `List 4 Text 0`
},
{
id: 1,
text: `List 4 Text 1`
},
{
id: 2,
text: `List 4 Text 2`
},
{
id: 3,
text: `List 4 Text 3`
}
]
};
},
methods: {
onDrop(collection, dropResult) {
this[collection] = applyDrag(this[collection], dropResult);
},
getChildPayload1(index) {
return this.listOne[index];
},
getChildPayload2(index) {
return this.listTwo[index];
},
getChildPayload3(index) {
return this.listThree[index];
},
getChildPayload4(index) {
return this.listFour[index];
}
}
};
</script>
<style scoped>
.card-list-container {
display: flex;
justify-content: space-evenly;
}
.smooth-dnd-container {
display: flex;
flex-direction: column;
width: 40%;
max-width: 40%;
flex: 0 0 40%;
height: 100%;
border: 1px solid #dcebf4;
border-radius: 6px;
padding: 1rem 1rem 0 1rem;
margin-top: 5rem;
margin-right: 2.5rem;
margin-left: 1rem;
}
</style>

Don’t panic! This looks like a lot, but let me break it down;

  1. We’ve imported our Container & Draggable components from the plugin. These components add the CSS classes necessary for the inbuilt drag and drop animation, and the Container component is where all the magic happens.
  2. Earlier, we hardcoded the card text in our Card component and declared our CardList component four times in our App.vue file. Now, that’s not necessary. What we have done is created four different arrays; one for each vertical list. We’ve called them listOne, listTwo etc…
  3. The entire object in each array is sent as props to the Card component.
  4. The @drop=”onDrop” is where the actual drag and drop functionality happens.

From the docs;

@drop

The event to be emitted by any relevant container when drop is over. (After drop animation ends). Source container and any container that could accept drop is considered relevant.

@drop="onDrop('listOne', $event)"

Lets break down what is happening here;

We send the name of our list as a parameter to the onDrop method, along with our DOM event using vue’s special $event variable. Then, we use our helper applyDrag helper function to determine where to remove the dragged element from & where to add it(i.e. the place where it has been dropped); whether it is a separate list or dragged and dropped in the same list.

onDrop(collection, dropResult) {
this[collection] = applyDrag(this[collection], dropResult);
}

Now what exactly does the dropResult param contain? From the docs;

dropResult : object

removedIndex : number : index of the removed child. Will be null if no item is removed.

addedIndex : number : index to add dropped item. Will be null if no item is added.

payload : object : the payload object retrieved by calling get-child-payload function.

droppedElement : DOMElement : the DOM element that is moved

We’ve used our get-child-payload prop to get the info about the card that we’re dragging and dropping.

For instance, if we moved the first card in our first vertical list, the payload would be the following;

{
id: 0;
text: "List 1 Text 0";
}

Here’s our applyDrag helper function that we had imported in our CardList.vue file

export const applyDrag = (arr, dragResult) => {
const { removedIndex, addedIndex, payload } = dragResult;
if (removedIndex === null && addedIndex === null) return arr;
const result = [...arr];
let itemToAdd = payload;
if (removedIndex !== null) {
itemToAdd = result.splice(removedIndex, 1)[0];
}
if (addedIndex !== null) {
result.splice(addedIndex, 0, itemToAdd);
}
return result;
};

We use the group-name prop to drag and drop items from one list to another.

Otherwise, we would be restricted to dragging and dropping in the same list.

From the docs;

:group-name: Draggables can be moved between the containers having the same group names. If not set container will not accept drags from outside.

Great! Now, we can drag and drop in the same list or drag and drop from one list to another. All that’s left is the Trello-style drop placeholder, drag placeholder & the card-rotate animation.

Here’s our updated CardList.vue file;

<template>
<div class="card-list-container">
<Container
drag-class="card-ghost"
drop-class="card-ghost-drop"
:drop-placeholder="dropPlaceholderOptions"
:get-child-payload="getChildPayload1"
group-name="1"
@drop="onDrop('listOne', $event)"
>
<Draggable v-for="(item, $index) in listOne" :key="$index">
<Card :item="item" />
</Draggable>
</Container>
<Container
drag-class="card-ghost"
drop-class="card-ghost-drop"
:drop-placeholder="dropPlaceholderOptions"
:get-child-payload="getChildPayload2"
group-name="1"
@drop="onDrop('listTwo', $event)"
>
<Draggable v-for="(item, $index) in listTwo" :key="$index">
<Card :item="item" />
</Draggable>
</Container>
<Container
drag-class="card-ghost"
drop-class="card-ghost-drop"
:drop-placeholder="dropPlaceholderOptions"
:get-child-payload="getChildPayload3"
group-name="1"
@drop="onDrop('listThree', $event)"
>
<Draggable v-for="(item, $index) in listThree" :key="$index">
<Card :item="item" />
</Draggable>
</Container>
<Container
drag-class="card-ghost"
drop-class="card-ghost-drop"
:drop-placeholder="dropPlaceholderOptions"
:get-child-payload="getChildPayload4"
group-name="1"
@drop="onDrop('listFour', $event)"
>
<Draggable v-for="(item, $index) in listFour" :key="$index">
<Card :item="item" />
</Draggable>
</Container>
</div>
</template>
<script>
import { Container, Draggable } from "vue-smooth-dnd";
import { applyDrag } from "../utils/applyDrag";
import Card from "./Card";
export default {
name: "CardList",
components: {
Card,
Container,
Draggable
},
data() {
return {
dropPlaceholderOptions: {
className: "drop-preview",
animationDuration: "150",
showOnTop: false
},
listOne: [
{
id: 0,
text: `List 1 Text 0`
},
{
id: 1,
text: `List 1 Text 1`
},
{
id: 2,
text: `List 1 Text 2`
},
{
id: 3,
text: `List 1 Text 3`
}
],
listTwo: [
{
id: 0,
text: `List 2 Text 0`
},
{
id: 1,
text: `List 2 Text 1`
},
{
id: 2,
text: `List 2 Text 2`
},
{
id: 3,
text: `List 2 Text 3`
}
],
listThree: [
{
id: 0,
text: `List 3 Text 0`
},
{
id: 1,
text: `List 3 Text 1`
},
{
id: 2,
text: `List 3 Text 2`
},
{
id: 3,
text: `List 3 Text 3`
}
],
listFour: [
{
id: 0,
text: `List 4 Text 0`
},
{
id: 1,
text: `List 4 Text 1`
},
{
id: 2,
text: `List 4 Text 2`
},
{
id: 3,
text: `List 4 Text 3`
}
]
};
},
methods: {
onDrop(collection, dropResult) {
this[collection] = applyDrag(this[collection], dropResult);
},
getChildPayload1(index) {
return this.listOne[index];
},
getChildPayload2(index) {
return this.listTwo[index];
},
getChildPayload3(index) {
return this.listThree[index];
},
getChildPayload4(index) {
return this.listFour[index];
}
}
};
</script>
<style scoped>
.card-list-container {
display: flex;
justify-content: space-evenly;
}
.smooth-dnd-container {
display: flex;
flex-direction: column;
width: 40%;
max-width: 40%;
flex: 0 0 40%;
height: 100%;
border: 1px solid #dcebf4;
border-radius: 6px;
padding: 1rem 1rem 0 1rem;
margin-top: 5rem;
margin-right: 2.5rem;
margin-left: 1rem;
}
.card-ghost {
transition: transform 0.18s ease;
transform: rotateZ(5deg);
}
.card-ghost-drop {
transition: transform 0.18s ease-in-out;
transform: rotateZ(0deg);
}
</style>

To style our dragged card & to add a drop placeholder, we’ve used the drag-class, drop-class & drop-placeholder props.

From the docs;

:drag-class: Class to be added to the ghost item being dragged. The class will be added after it's added to the DOM so any transition in the class will be applied as intended.

:drop-class: Class to be added to the ghost item just before the drop animation begins.

We use a simple rotateZ transform to rotate our dragged card like Trello does, and as soon as the card is about to be dropped, we rotate it back 0 deg.

Since we’ve scoped our styles, adding the styles for the drop-preview class in our CardList.vue component won’t work.

We can instead add the styles for it in our App.vue class;

<template>
<div id="app">
<CardList />
</div>
</template>
<script>
import CardList from "./components/CardList.vue";
export default {
name: "App",
components: {
CardList
}
};
</script>
<style>
body {
background: #f6f9fc;
}
#app {
display: flex;
justify-content: space-evenly;
align-items: center;
font-family: "Roboto", sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
:root {
font-size: 10px;
}
.drop-preview {
background-color: rgba(150, 150, 200, 0.1);
margin: 1rem 2rem 1rem 0.3rem;
}
</style>

Written by

25. Front-end engineer passionate about everything web-related.

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store