In the last blog post, I built a reusable drag-and-drop component in React. I called it Drag
. To test it out, I recreated one of my favourite drag-and-drop experiences - Trello!
Here's what we will build:
See the Pen Trello-style React Drag and Drop Lists by Codemzy (@codemzy) on CodePen.
You'll need to grab the code for the Drag
component to follow along with this tutorial.
The data
Trello boards are primarily lists and cards.
Since we are focusing on the drag-and-drop features of Trello (and no recreating a real Trello clone), let's start with some simple dummy data to represent our lists and cards.
// the dummy Trello-style content
const dummyData = [
{ id: 1, name: "List 1", cards: [
{ id: 1, title: "Card 1" },
{ id: 2, title: "Card 2" },
{ id: 3, title: "Card 3" },
{ id: 4, title: "Card 4" },
{ id: 5, title: "Card 5" },
] },
{ id: 2, name: "List 2", cards: [
{ id: 6, title: "Card 6" },
{ id: 7, title: "Card 7" },
{ id: 8, title: "Card 8" },
] },
];
Our dummy data is an array, and each item is an object that represents a list.
Each list has a cards
property that is also an array. Each item in that array is an object that represents a card.
The design
Let's start by making a board that looks like Trello.
I'm using Tailwind CSS to style my Trello-like board. I use Tailwind for most of my projects and find it helps me style things quickly without writing a bunch of custom CSS.
If you prefer not to use Tailwind CSS, you can get all the styles for the classes I use in this tutorial from the Tailwind CSS docs.
Here's the board:
<div id="app"></div>
// the dummy Trello-style content const dummyData = [ { id: 1, name: "List 1", cards: [ { id: 1, title: "Card 1" }, { id: 2, title: "Card 2" }, { id: 3, title: "Card 3" }, { id: 4, title: "Card 4" }, { id: 5, title: "Card 5" }, ] }, { id: 2, name: "List 2", cards: [ { id: 6, title: "Card 6" }, { id: 7, title: "Card 7" }, { id: 8, title: "Card 8" }, ] }, ]; function Card({ title, description = "Drag and drop me!" }) { return ( <div className="rounded-lg bg-white border border-gray-300 shadow-sm p-5 m-2"> <h3 className="font-bold text-lg my-1">{ title }</h3> <p>{ description }</p> </div> ); }; function List({ name, children }) { return ( <div className="rounded-xl bg-gray-100 p-2 mx-2 my-5 w-80 shrink-0 grow-0 shadow"> <div className="px-6 py-1"> <h2 className="font-bold text-xl my-1">{ name }</h2> </div> { children } </div> ); }; // app component function App() { const [data, setData] = React.useState(dummyData); return ( <div className="p-10 flex flex-col h-screen"> <h1 className="font-semibold text-3xl py-2">Trello-Style Drag & Drop</h1> <p>Let's drag some cards around!</p> <div className="flex items-start -mx-2 overflow-x-scroll h-full"> {data.map((list, listPosition) => { return ( <List key={list.id} name={list.name}> {data[listPosition].cards.map((card) => { return ( <Card key={card.id} title={card.title} /> ); })} </List> ); })} </div> </div> ); }; // ======================================== // render the react app ReactDOM.render( <App />, document.getElementById('app') );
Again, we'll focus on the drag-and-drop features, but I'll quickly discuss the code for this UI before we start dragging and dropping.
Card Component
import React from 'react';
function Card({ title, description = "Drag and drop me!" }) {
return (
<div className="rounded-lg bg-white border border-gray-300 shadow-sm p-5 m-2">
<h3 className="font-bold text-lg my-1">{ title }</h3>
<p>{ description }</p>
</div>
);
};
export default Card;
The card is rounded and white, with a border and a small shadow. It takes a title prop (from our data) and a description - which I've just given a default string for now.
List
import React from 'react';
function List({ name, children }) {
return (
<div className="rounded-xl bg-gray-100 p-2 mx-2 my-5 w-80 shrink-0 grow-0 shadow">
<div className="px-6 py-1">
<h2 className="font-bold text-xl my-1">{ name }</h2>
</div>
{ children }
</div>
);
};
export default List;
The list is rounded and grey with a small shadow, and the list name is displayed at the top - just like Trello!
App
import React from 'react';
function App() {
const [data, setData] = React.useState(dummyData);
return (
<div className="p-10 flex flex-col h-screen">
<h1 className="font-semibold text-3xl py-2">Trello-Style Drag & Drop</h1>
<p>Let's drag some cards around!</p>
<div className="flex items-start -mx-2 overflow-x-scroll h-full">
{data.map((list, listPosition) => {
return (
<List key={list.id} name={list.name}>
{data[listPosition].cards.map((card) => {
return (
<Card key={card.id} title={card.title} />
);
})}
</List>
);
})}
</div>
</div>
);
};
export default App;
The App
component is our board view. Our board contains our lists and cards.
We loop over our array of objects to display each list and card using the Array.map()
function.
Starting with the lists, we map the data
array and return a List
for each item. Then, inside each list, we map the cards
array and return a Card
for each item.
I think it looks Trello-like! Let's move on to adding the drag-and-drop features.
Dragging cards
For this part, you need the Drag
component we coded in the last blog post. The code for Drag
is available on Github to download if you want to skip the details!
Let's make our cards draggable!
import React from 'react';
import Drag from './Drag'; // import the Drag component
// app component
function App() {
const [data, setData] = React.useState(dummyData);
// handle a dropped item (TO DO)
function handleDrop({ dragItem, dragType, drop }) {
// do something
};
return (
<div className="p-10 flex flex-col h-screen">
<h1 className="font-semibold text-3xl py-2">Trello-Style Drag & Drop</h1>
<p>Let's drag some cards around!</p>
<Drag handleDrop={handleDrop}>
<div className="flex items-start -mx-2 overflow-x-scroll h-full">
{data.map((list, listPosition) => {
return (
<List key={list.id} name={list.name}>
{data[listPosition].cards.map((card) => {
return (
<Drag.DragItem key={card.id} dragId={card.id} dragType="card">
<Card title={card.title} />
</Drag.DragItem>
);
})}
</List>
);
})}
</div>
</Drag>
</div>
);
};
export default App;
Okay, a few things have happened here.
We have imported the Drag
component (that we built earlier).
import Drag from './Drag';
We have wrapped our list area in the Drag
component and our Card
s in Drag.DragItem
(to make them draggable!).
The DragItem
takes a dragId
so we know which item is getting dragged - this will be important later when we need to handle the drop. Each of our cards has a unique ID (card.id
), and we can use that.
return (
<Drag.DragItem key={card.id} dragId={card.id} dragType="card">
<Card title={card.title} />
</Drag.DragItem>
);
We should also add the "select-none" class to the List
component to prevent selecting text on the lists or cards - because that will interfere with our drag-and-drop.
<div className="rounded-xl bg-gray-100 p-2 mx-2 my-5 w-80 shrink-0 grow-0 shadow select-none">
Now we can drag our cards around - we can't drop them yet - but we will fix that shortly.
<div id="app"></div>
// context for the drag const DragContext = React.createContext(); // drag context component function Drag({ draggable = true, handleDrop, children }) { const [dragType, setDragType] = React.useState(null); // if multiple types of drag item const [dragItem, setDragItem] = React.useState(null); // the item being dragged const [isDragging, setIsDragging] = React.useState(null); const [drop, setDrop] = React.useState(null); // the active dropzone React.useEffect(() => { if (dragItem) { document.body.style.cursor = "grabbing"; } else { document.body.style.cursor = "default"; } }, [dragItem]); const dragStart = function(e, dragId, dragType) { e.stopPropagation(); e.dataTransfer.effectAllowed = 'move'; setDragItem(dragId); setDragType(dragType); }; const drag = function(e, dragId, dragType) { e.stopPropagation(); setIsDragging(true); }; const dragEnd = function(e) { setDragItem(null); setDragType(null); setIsDragging(false); setDrop(null); }; const onDrop = function(e) { e.preventDefault(); handleDrop({ dragItem, dragType, drop }); setDragItem(null); setDragType(null); setIsDragging(false); setDrop(null); }; return ( <DragContext.Provider value={{ draggable, dragItem, dragType, isDragging, dragStart, drag, dragEnd, drop, setDrop, onDrop }}> { typeof children === "function" ? children({ activeItem: dragItem, activeType: dragType, isDragging }) : children } </DragContext.Provider> ); }; // a draggable item Drag.DragItem = function({ as, dragId, dragType, ...props }) { const { draggable, dragStart, drag, dragEnd, dragItem } = React.useContext(DragContext); let Component = as || "div"; return <Component onDragStart={(e) => dragStart(e, dragId, dragType)} onDrag={drag} draggable={draggable} onDragEnd={dragEnd} {...props} />; }; // listens for drags over drop zones Drag.DropZone = function({ as, dropId, dropType, remember, children, style, ...props }) { const { dragItem, dragType, setDrop, drop, onDrop } = React.useContext(DragContext); function handleDragOver(e) { if (e.preventDefault) { e.preventDefault(); } return false; }; function handleLeave() { if (!remember) { setDrop(null); } }; let Component = as || "div"; return ( <Component onDragEnter={(e) => dragItem && dropType === dragType && setDrop(dropId)} onDragOver={handleDragOver} onDrop={onDrop} style={{position: "relative", ...style}} {...props}> { children } { drop === dropId && <div style={{position: "absolute", inset: "0px"}} onDragLeave={handleLeave}></div> } </Component> ); }; // if we need multiple dropzones Drag.DropZones = function({ dropType, prevId, nextId, split = "y", remember, children }) { const { dragType, isDragging } = React.useContext(DragContext); return ( <div style={{position: "relative"}}> { children } { dragType === dropType && isDragging && <div style={{position: "absolute", inset: "0px" }}> <Drag.DropZone dropId={prevId} style={ split === "x" ? { width: "50%" } : { height: "50%" }} dropType={dropType} remember={remember} /> <Drag.DropZone dropId={nextId} style={ split === "x" ? { width: "50%" } : { height: "50%" }} dropType={dropType} remember={remember} /> </div> } </div> ); }; // indicates where the drop will go when dragging over a dropzone Drag.DropGuide = function({ as, dropId, dropType, ...props }) { const { drop, dragType } = React.useContext(DragContext); let Component = as || "div"; return dragType === dropType && drop === dropId ? <Component {...props} /> : null; }; // the dummy Trello-style content const dummyData = [ { id: 1, name: "List 1", cards: [ { id: 1, title: "Card 1" }, { id: 2, title: "Card 2" }, { id: 3, title: "Card 3" }, { id: 4, title: "Card 4" }, { id: 5, title: "Card 5" }, ] }, { id: 2, name: "List 2", cards: [ { id: 6, title: "Card 6" }, { id: 7, title: "Card 7" }, { id: 8, title: "Card 8" }, ] }, ]; function Card({ title, description = "Drag and drop me!" }) { return ( <div className="rounded-lg bg-white border border-gray-300 shadow-sm p-5 m-2"> <h3 className="font-bold text-lg my-1">{ title }</h3> <p>{ description }</p> </div> ); }; function List({ name, children }) { return ( <div className="rounded-xl bg-gray-100 p-2 mx-2 my-5 w-80 shrink-0 grow-0 shadow select-none"> <div className="px-6 py-1"> <h2 className="font-bold text-xl my-1">{ name }</h2> </div> { children } </div> ); }; // app component function App() { const [data, setData] = React.useState(dummyData); function handleDrop({ dragItem, dragType, drop }) { // do something }; return ( <div className="p-10 flex flex-col h-screen"> <h1 className="font-semibold text-3xl py-2">Trello-Style Drag & Drop</h1> <p>Let's drag some cards around!</p> <Drag handleDrop={handleDrop}> <div className="flex items-start -mx-2 overflow-x-scroll h-full"> {data.map((list, listPosition) => { return ( <List key={list.id} name={list.name}> {data[listPosition].cards.map((card) => { return ( <Drag.DragItem key={card.id} dragId={card.id} dragType="card"> <Card title={card.title} /> </Drag.DragItem> ); })} </List> ); })} </div> </Drag> </div> ); }; // ======================================== // render the react app ReactDOM.render( <App />, document.getElementById('app') );
First, there are a couple of problems.
- In Trello cards disappear from the list during a drag, but ours stays on the page
- Our dragged card includes part of the background and doesn't have rounded corners
Hiding the original element
Usually, when I do a drag and drop, I leave the item I am dragging in the DOM but fade it out (by reducing the opacity).
Trello doesn't do this. Trello hides the orginal element (the card) while it gets dragged, and only shows it as the drag image.
Let's make that happen!
Drag
provides us with three render props we can use, activeItem
, activeType
, and isDragging
.
<Drag handleDrop={handleDrop}>
{({ activeItem, activeType, isDragging }) => ( // 🆕 render props
<div className="flex items-start -mx-2 overflow-x-scroll h-full">
{data.map((list, listPosition) => {
return (
<List key={list.id} name={list.name}>
{data[listPosition].cards.map((card) => {
return (
<Drag.DragItem key={card.id} dragId={card.id} className={`cursor-pointer ${activeItem === card.id && activeType === "card" ? "hidden" : ""}`} dragType="card">
<Card title={card.title} />
</Drag.DragItem>
);
})}
</List>
);
})}
</div>
)}
</Drag>
Now I can check if the render props match the card, and if they do, add the "hidden" class to hide it from view. I've also added the "cursor-pointer" class to change the cursor and provide a little clue that it is interactive.
className={`cursor-pointer ${activeItem === card.id && activeType === "card" && isDragging ? "hidden" : ""}`}
My Drag
component handles hidden elements (now), but it was tricky to implement, so let me explain how I fixed it.
At first, I changed the styles of my dragged items on the "dragstart" event. But because the drag image gets created by the browser at the end of the dragStart
event, any styles got picked up in the drag image.
That means if I hide a dragged item, it will also get hidden in the drag image! Whoops!
The styles needed to be set after the "dragstart" event instead.
But to add to the complexity, I do need to change some styles on the "dragstart" event, because Trello rotates the drag image card slightly so it's at a cool jaunty angle when you drag it.
In my Drag
component, I added a isDragging
state. This is only true once the drag event is happening (using the onDrag
event instead of onDragStart
). That means the isDragging
render prop comes after the drag has started and I can safely hide the card once that is true.
Giving the drag element rounded corners
Another little bug that had me stumped was the drag image. My cards have rounded corners (like Trello). But when the drag image (created in the browser during a drag) has square corners.
And that means you can see the background in the corners of the drag image.
That does not look good - I need a fix!
Luckily, I found a pretty weird solution in this GitHub issue - use "translate-x-0".
<Drag.DragItem key={card.id} dragId={card.id} className={`cursor-pointer ${activeItem === card.id && activeType === "card" && isDragging ? "hidden" : "translate-x-0"}`} dragType="card">
With that style added, the rounded corners don't get picked up in the drag image - sweet!
Rotating the drag image
When you drag an item, the browser will screenshot the DOM node being dragged. And that's native HTML5 behaviour that works out the box.
But when you drag a card in Trello, the drag image gets rotated slightly - which I think looks pretty neat!
We want to add this style when the drag event starts, but before our original element gets hidden.
So we will rotate our card when the activeItem
is set (before isDragging
). Then our card will rotate and the drag image will be created before the original element is hidden (the code we just did).
And another little tip - we need to add this style inside the drag element - adding it the DragItem
won't work, we need to rotate an element inside it. So let's rotate the Card
component instead.
Here's how the code looks:
<Card title={card.title} dragItem={activeItem === card.id && activeType === "card"} />
Now the Card
element knows when it is the drag item.
function Card({ title, description = "Drag and drop me!", dragItem }) {
return (
<div className={`rounded-lg bg-white border border-gray-300 shadow-sm p-5 m-2${ dragItem ? " rotate-6" : ""}`}>
<h3 className="font-bold text-lg my-1">{ title }</h3>
<p>{ description }</p>
</div>
);
};
And when it's the drag item, it rotates!
<div id="app"></div>
// context for the drag const DragContext = React.createContext(); // drag context component function Drag({ draggable = true, handleDrop, children }) { const [dragType, setDragType] = React.useState(null); // if multiple types of drag item const [dragItem, setDragItem] = React.useState(null); // the item being dragged const [isDragging, setIsDragging] = React.useState(null); const [drop, setDrop] = React.useState(null); // the active dropzone React.useEffect(() => { if (dragItem) { document.body.style.cursor = "grabbing"; } else { document.body.style.cursor = "default"; } }, [dragItem]); const dragStart = function(e, dragId, dragType) { e.stopPropagation(); e.dataTransfer.effectAllowed = 'move'; setDragItem(dragId); setDragType(dragType); }; const drag = function(e, dragId, dragType) { e.stopPropagation(); setIsDragging(true); }; const dragEnd = function(e) { setDragItem(null); setDragType(null); setIsDragging(false); setDrop(null); }; const onDrop = function(e) { e.preventDefault(); handleDrop({ dragItem, dragType, drop }); setDragItem(null); setDragType(null); setIsDragging(false); setDrop(null); }; return ( <DragContext.Provider value={{ draggable, dragItem, dragType, isDragging, dragStart, drag, dragEnd, drop, setDrop, onDrop }}> { typeof children === "function" ? children({ activeItem: dragItem, activeType: dragType, isDragging }) : children } </DragContext.Provider> ); }; // a draggable item Drag.DragItem = function({ as, dragId, dragType, ...props }) { const { draggable, dragStart, drag, dragEnd, dragItem } = React.useContext(DragContext); let Component = as || "div"; return <Component onDragStart={(e) => dragStart(e, dragId, dragType)} onDrag={drag} draggable={draggable} onDragEnd={dragEnd} {...props} />; }; // listens for drags over drop zones Drag.DropZone = function({ as, dropId, dropType, remember, children, style, ...props }) { const { dragItem, dragType, setDrop, drop, onDrop } = React.useContext(DragContext); function handleDragOver(e) { if (e.preventDefault) { e.preventDefault(); } return false; }; function handleLeave() { if (!remember) { setDrop(null); } }; let Component = as || "div"; return ( <Component onDragEnter={(e) => dragItem && dropType === dragType && setDrop(dropId)} onDragOver={handleDragOver} onDrop={onDrop} style={{position: "relative", ...style}} {...props}> { children } { drop === dropId && <div style={{position: "absolute", inset: "0px"}} onDragLeave={handleLeave}></div> } </Component> ); }; // if we need multiple dropzones Drag.DropZones = function({ dropType, prevId, nextId, split = "y", remember, children }) { const { dragType, isDragging } = React.useContext(DragContext); return ( <div style={{position: "relative"}}> { children } { dragType === dropType && isDragging && <div style={{position: "absolute", inset: "0px" }}> <Drag.DropZone dropId={prevId} style={ split === "x" ? { width: "50%" } : { height: "50%" }} dropType={dropType} remember={remember} /> <Drag.DropZone dropId={nextId} style={ split === "x" ? { width: "50%" } : { height: "50%" }} dropType={dropType} remember={remember} /> </div> } </div> ); }; // indicates where the drop will go when dragging over a dropzone Drag.DropGuide = function({ as, dropId, dropType, ...props }) { const { drop, dragType } = React.useContext(DragContext); let Component = as || "div"; return dragType === dropType && drop === dropId ? <Component {...props} /> : null; }; // the dummy Trello-style content const dummyData = [ { id: 1, name: "List 1", cards: [ { id: 1, title: "Card 1" }, { id: 2, title: "Card 2" }, { id: 3, title: "Card 3" }, { id: 4, title: "Card 4" }, { id: 5, title: "Card 5" }, ] }, { id: 2, name: "List 2", cards: [ { id: 6, title: "Card 6" }, { id: 7, title: "Card 7" }, { id: 8, title: "Card 8" }, ] }, ]; function Card({ title, description = "Drag and drop me!", dragItem }) { return ( <div className={`rounded-lg bg-white border border-gray-300 shadow-sm p-5 m-2${ dragItem ? " rotate-6" : ""}`}> <h3 className="font-bold text-lg my-1">{ title }</h3> <p>{ description }</p> </div> ); }; function List({ name, children }) { return ( <div className="rounded-xl bg-gray-100 p-2 mx-2 my-5 w-80 shrink-0 grow-0 shadow select-none"> <div className="px-6 py-1"> <h2 className="font-bold text-xl my-1">{ name }</h2> </div> { children } </div> ); }; // app component function App() { const [data, setData] = React.useState(dummyData); // handle a dropped item (TO DO) function handleDrop({ dragItem, dragType, drop }) { // do something }; return ( <div className="p-10 flex flex-col h-screen"> <h1 className="font-semibold text-3xl py-2">Trello-Style Drag & Drop</h1> <p>Let's drag some cards around!</p> <Drag handleDrop={handleDrop}> {({ activeItem, activeType, isDragging }) => ( <div className="flex items-start -mx-2 overflow-x-scroll h-full"> {data.map((list, listPosition) => { return ( <List key={list.id} name={list.name}> {data[listPosition].cards.map((card) => { return ( <Drag.DragItem key={card.id} dragId={card.id} className={`cursor-pointer ${activeItem === card.id && activeType === "card" && isDragging ? "hidden" : "translate-x-0"}`} dragType="card"> <Card title={card.title} dragItem={activeItem === card.id && activeType === "card"} /> </Drag.DragItem> ); })} </List> ); })} </div> )} </Drag> </div> ); }; // ======================================== // render the react app ReactDOM.render( <App />, document.getElementById('app') );
Dropping cards
We can drag our cards, but we can't drop them yet. To drop cards, we need some drop zones!
How Trello handles drop zones is different because the drop zones aren't empty boxes that you drop things in. As you drag a card around, drop zones appear.
Here's how I think of it:
- each card is a drop zone (letting you drop a card above or below it)
- the end of each list is a drop zone (letting you drop a card at the end of the list)
- the board is a drop zone (handling the last drop zone you triggered)
Each card is a drop zone
Let's start with drop zones around cards.
{data[listPosition].cards.map((card, cardPosition) => {
return (
<Drag.DropZone key={card.id} dropId={`${listPosition}-${cardPosition}`} dropType="card">
<Drag.DragItem dragId={card.id} className={`cursor-pointer ${activeItem === card.id && activeType === "card" && isDragging ? "hidden" : "translate-x-0"}`} dragType="card">
<Card title={card.title} dragItem={activeItem === card.id && activeType === "card"} />
</Drag.DragItem>
</Drag.DropZone>
);
})}
I pass the Drag.DropZone
a dropId
, to let me know which drop zone is active. If you have only one list, this ID could simply be the cardPosition
. However, we are going to have drop zones in multiple lists, so my ID is ${listPosition}-${cardPosition}
so that I know both:
- which list the user wants to drop the card in
- the position in that list
Great - our drop zone is in place.
Adding DropGuide
s
Our DropZone
s are wrappers around our cards, but when we drag an item, we don't want it to replace the card. We want the dragged card placed above or below the drop zone.
That's why I created the Drag.DropGuide
component.
The DropGuide
says "if you let go, here's where you will land".
In Trello, the DropGuide
is a grey box that fills the space where the dragged card will go if you drop it.
Let's add a DropGuide
so that you can see what I mean.
return (
<Drag.DropZone key={card.id} dropId={`${listPosition}-${cardPosition}`} dropType="card">
<Drag.DropGuide dropId={`${listPosition}-${cardPosition}`} className="rounded-lg bg-gray-200 h-24 m-2" dropType="card" />
<Drag.DragItem dragId={card.id} className={`cursor-pointer ${activeItem === card.id && activeType === "card" && isDragging ? "hidden" : "translate-x-0"}`} dragType="card">
<Card title={card.title} dragItem={activeItem === card.id && activeType === "card"} />
</Drag.DragItem>
</Drag.DropZone>
);
The Drag.DropGuide
takes a dropId
and whenever that dropId
is active the Drag.DropGuide
will display. So in this case, the dropId
matches the one for the Drag.DropZone
- when the drop zone is active, the drop guide will display.
If you're coding along, you might notice that the guide disappears when you move off a drop zone. But in Trello, you can move away from a drop zone, and it remembers the last active drop zone.
I added a remember
prop to my drop zones so that this will work.
<Drag.DropZone ... remember={true}>
The end of the list is a drop zone
In Trello, if you drag a card at the bottom of the list (or below a list), it activates the drop zone at the bottom of that list.
To do this, I need two drop zones. And another drop guide.
All my other drop guides are above each card, but if we want to drag a card to the bottom of a list, we need a guide there too. We can only drop a card in a drop zone, so if we drop a card on that guide, it needs to be in a drop zone to handle the drop.
Let's do this!
<List key={list.id} name={list.name}>
{data[listPosition].cards.map((card, cardPosition) => {
return (
<Drag.DropZone key={card.id} dropId={`${listPosition}-${cardPosition}`} dropType="card" remember={true}>
<Drag.DropGuide dropId={`${listPosition}-${cardPosition}`} className="rounded-lg bg-gray-200 h-24 m-2" dropType="card" />
<Drag.DragItem dragId={card.id} className={`cursor-pointer ${activeItem === card.id && activeType === "card" && isDragging ? "hidden" : "translate-x-0"}`} dragType="card">
<Card title={card.title} dragItem={activeItem === card.id && activeType === "card"} />
</Drag.DragItem>
</Drag.DropZone>
);
})}
{/* 🆕 */}
<Drag.DropZone dropId={`${listPosition}-${data[listPosition].cards.length}`} dropType="card" remember={true}>
<Drag.DropGuide dropId={`${listPosition}-${data[listPosition].cards.length}`} className="rounded-lg bg-gray-200 h-24 m-2" dropType="card" />
</Drag.DropZone>
</List>
This drop zone activates the guide at the bottom of the list, so the ID is ${listPosition}-${data[listPosition].cards.length}
to be the last item in the array.
We also need a drop zone below the list. To get this to work Trello-style, I'll wrap each list in a "flex" div and have an invisible DropZone
at the bottom to listen for drags under the list.
<Drag handleDrop={handleDrop}>
{({ activeItem, activeType }) => (
<div className="flex items-start -mx-2 overflow-x-scroll h-full">
{data.map((list, listPosition) => {
return (
<div key={list.id} className="flex flex-col h-full">
<List name={list.name}>
{data[listPosition].cards.map((card, cardPosition) => {
return (
<Drag.DropZone key={card.id} dropId={`${listPosition}-${cardPosition}`} dropType="card" remember={true}>
<Drag.DropGuide dropId={`${listPosition}-${cardPosition}`} className="rounded-lg bg-gray-200 h-24 m-2" dropType="card" />
<Drag.DragItem dragId={card.id} className={`cursor-pointer ${activeItem === card.id && activeType === "card" && isDragging ? "hidden" : "translate-x-0"}`} dragType="card">
<Card title={card.title} dragItem={activeItem === card.id && activeType === "card"} />
</Drag.DragItem>
</Drag.DropZone>
);
})}
<Drag.DropZone dropId={`${listPosition}-${data[listPosition].cards.length}`} dropType="card" remember={true}>
<Drag.DropGuide dropId={`${listPosition}-${data[listPosition].cards.length}`} className="rounded-lg bg-gray-200 h-24 m-2" dropType="card" />
</Drag.DropZone>
</List>
<Drag.DropZone dropId={`${listPosition}-${data[listPosition].cards.length}`} className="grow" dropType="card" remember={true} />
</div>
);
})}
</div>
)}
</Drag>
This bottom drop zone also activates the last drop zone on the list (the one we created earlier).
<div id="app"></div>
// context for the drag const DragContext = React.createContext(); // drag context component function Drag({ draggable = true, handleDrop, children }) { const [dragType, setDragType] = React.useState(null); // if multiple types of drag item const [dragItem, setDragItem] = React.useState(null); // the item being dragged const [isDragging, setIsDragging] = React.useState(null); const [drop, setDrop] = React.useState(null); // the active dropzone React.useEffect(() => { if (dragItem) { document.body.style.cursor = "grabbing"; } else { document.body.style.cursor = "default"; } }, [dragItem]); const dragStart = function(e, dragId, dragType) { e.stopPropagation(); e.dataTransfer.effectAllowed = 'move'; setDragItem(dragId); setDragType(dragType); }; const drag = function(e, dragId, dragType) { e.stopPropagation(); setIsDragging(true); }; const dragEnd = function(e) { setDragItem(null); setDragType(null); setIsDragging(false); setDrop(null); }; const onDrop = function(e) { e.preventDefault(); handleDrop({ dragItem, dragType, drop }); setDragItem(null); setDragType(null); setIsDragging(false); setDrop(null); }; return ( <DragContext.Provider value={{ draggable, dragItem, dragType, isDragging, dragStart, drag, dragEnd, drop, setDrop, onDrop }}> { typeof children === "function" ? children({ activeItem: dragItem, activeType: dragType, isDragging }) : children } </DragContext.Provider> ); }; // a draggable item Drag.DragItem = function({ as, dragId, dragType, ...props }) { const { draggable, dragStart, drag, dragEnd, dragItem } = React.useContext(DragContext); let Component = as || "div"; return <Component onDragStart={(e) => dragStart(e, dragId, dragType)} onDrag={drag} draggable={draggable} onDragEnd={dragEnd} {...props} />; }; // listens for drags over drop zones Drag.DropZone = function({ as, dropId, dropType, remember, children, style, ...props }) { const { dragItem, dragType, setDrop, drop, onDrop } = React.useContext(DragContext); function handleDragOver(e) { if (e.preventDefault) { e.preventDefault(); } return false; }; function handleLeave() { if (!remember) { setDrop(null); } }; let Component = as || "div"; return ( <Component onDragEnter={(e) => dragItem && dropType === dragType && setDrop(dropId)} onDragOver={handleDragOver} onDrop={onDrop} style={{position: "relative", ...style}} {...props}> { children } { drop === dropId && <div style={{position: "absolute", inset: "0px"}} onDragLeave={handleLeave}></div> } </Component> ); }; // if we need multiple dropzones Drag.DropZones = function({ dropType, prevId, nextId, split = "y", remember, children }) { const { dragType, isDragging } = React.useContext(DragContext); return ( <div style={{position: "relative"}}> { children } { dragType === dropType && isDragging && <div style={{position: "absolute", inset: "0px" }}> <Drag.DropZone dropId={prevId} style={ split === "x" ? { width: "50%" } : { height: "50%" }} dropType={dropType} remember={remember} /> <Drag.DropZone dropId={nextId} style={ split === "x" ? { width: "50%" } : { height: "50%" }} dropType={dropType} remember={remember} /> </div> } </div> ); }; // indicates where the drop will go when dragging over a dropzone Drag.DropGuide = function({ as, dropId, dropType, ...props }) { const { drop, dragType } = React.useContext(DragContext); let Component = as || "div"; return dragType === dropType && drop === dropId ? <Component {...props} /> : null; }; // the dummy Trello-style content const dummyData = [ { id: 1, name: "List 1", cards: [ { id: 1, title: "Card 1" }, { id: 2, title: "Card 2" }, { id: 3, title: "Card 3" }, { id: 4, title: "Card 4" }, { id: 5, title: "Card 5" }, ] }, { id: 2, name: "List 2", cards: [ { id: 6, title: "Card 6" }, { id: 7, title: "Card 7" }, { id: 8, title: "Card 8" }, ] }, ]; function Card({ title, description = "Drag and drop me!", dragItem }) { return ( <div className={`rounded-lg bg-white border border-gray-300 shadow-sm p-5 m-2${ dragItem ? " rotate-6" : ""}`}> <h3 className="font-bold text-lg my-1">{ title }</h3> <p>{ description }</p> </div> ); }; function List({ name, children }) { return ( <div className="rounded-xl bg-gray-100 p-2 mx-2 my-5 w-80 shrink-0 grow-0 shadow select-none"> <div className="px-6 py-1"> <h2 className="font-bold text-xl my-1">{ name }</h2> </div> { children } </div> ); }; // app component function App() { const [data, setData] = React.useState(dummyData); // handle a dropped item (TO DO) function handleDrop({ dragItem, dragType, drop }) { // do something }; return ( <div className="p-10 flex flex-col h-screen"> <h1 className="font-semibold text-3xl py-2">Trello-Style Drag & Drop</h1> <p>Let's drag some cards around!</p> <Drag handleDrop={handleDrop}> {({ activeItem, activeType, isDragging }) => ( <div className="flex items-start -mx-2 overflow-x-scroll h-full"> {data.map((list, listPosition) => { return ( <div key={list.id} className="flex flex-col h-full"> <List name={list.name}> {data[listPosition].cards.map((card, cardPosition) => { return ( <Drag.DropZone key={card.id} dropId={`${listPosition}-${cardPosition}`} dropType="card" remember={true}> <Drag.DropGuide dropId={`${listPosition}-${cardPosition}`} className="rounded-lg bg-gray-200 h-24 m-2" dropType="card" /> <Drag.DragItem dragId={card.id} className={`cursor-pointer ${activeItem === card.id && activeType === "card" && isDragging ? "hidden" : "translate-x-0"}`} dragType="card"> <Card title={card.title} dragItem={activeItem === card.id && activeType === "card"} /> </Drag.DragItem> </Drag.DropZone> ); })} <Drag.DropZone dropId={`${listPosition}-${data[listPosition].cards.length}`} dropType="card" remember={true}> <Drag.DropGuide dropId={`${listPosition}-${data[listPosition].cards.length}`} className="rounded-lg bg-gray-200 h-24 m-2" dropType="card" /> </Drag.DropZone> </List> <Drag.DropZone dropId={`${listPosition}-${data[listPosition].cards.length}`} className="grow" dropType="card" remember={true} /> </div> ); })} </div> )} </Drag> </div> ); }; // ======================================== // render the react app ReactDOM.render( <App />, document.getElementById('app') );
The board is a drop zone
If you don't drop your card on a drop zone, it goes back to where it started. And that's how drag-and-drop works.
But in Trello, if you drop a card somewhere on the board, it gets dropped to the last drop zone you activated.
We need to make our board a drop zone!
This drop zone is a little different. It will have no dropId
.
And the reason for no dropId
? We only want to catch drop events but keep the last drop zone in the lists the user activated.
So let's turn the first div
inside our <Drag>
into a DropZone
.
From this:
<div className="flex items-start -mx-2 overflow-x-scroll h-full">
To this:
<Drag.DropZone className="flex -mx-2 overflow-x-scroll h-full">
Fixing the card drop zones
Okay, so I've made each card into a drop zone, and when you drag over a drop zone, the DropGuide
shows where it will move.
It’s working ok. If we drag an item over a drop zone, it sets drop
to that index, and the drop guide displays above that card, which is great but when we drag cards down, the drop position lags behind us
Dragging cards up looks great. Dragging cards down looks a bit off.
When I compare this with Trello, it looks like when a card drags in from the top, it gets positioned after the next card (instead of before it).
We can easily change this with setDrop(dropId + 1)
, but we would have the opposite problem. Cards dragged down would look great, but dragging up would lag.
I went through a bunch of ideas for how I could get this to work better.
First, I tried checking what direction the mouse was dragging (like my useScrollDirection
hook but for the "drag" event), and setting the position after the drop zone if it was moving downwards. This sort of worked - but not if the user changed direction while over a drop zone.
Then I tried using the onDrag
instead, but because the drop zones would jump about as you drag a card from one position to the next, it was hard to get a smooth experience.
And all the extra event listeners and debouncing I needed to track drag events and mouse movements were just adding complexity.
So I came up with a better idea...
Two drop zones per card!
Because two drop zones are better than one (in this case!).
Instead of one drop zone, I need my card to have two drop zones. One for the top half and one for the bottom half.
If you drag over the top half of the drop zone the drop position will be i
- placing it above the drop zone.
And if you drag into the bottom half, the drop position will be i+1
- placing it below the drop zone.
I feel like I'm going to need this double-drop zone feature whenever I build a drag-and-drop list like this, so I bundled this up into a DropZones
sub-component and added it to my Drag
component.
I want to split my cards in half by height, which is the default split, so all I need to do is change DropZone
to DropZones
and give two new props. prevId
which will be dropId
we already had. And nextId
where we will add 1 to the card position ${listPosition}-${cardPosition+1}
, activating the drop zone after the card (instead of before it).
{data[listPosition].cards.map((card, cardPosition) => {
return (
<Drag.DropZones key={card.id} prevId={`${listPosition}-${cardPosition}`} nextId={`${listPosition}-${cardPosition+1}`} dropType="card" remember={true}>
<Drag.DropGuide dropId={`${listPosition}-${cardPosition}`} className="rounded-lg bg-gray-200 h-24 m-2" dropType="card" />
<Drag.DragItem dragId={card.id} className={`cursor-pointer ${activeItem === card.id && activeType === "card" && isDragging ? "hidden" : "translate-x-0"}`} dragType="card">
<Card title={card.title} dragItem={activeItem === card.id && activeType === "card"} />
</Drag.DragItem>
</Drag.DropZones>
);
})}
We are only changing the DropZone
around the cards to this double DropZones
component. Our other DropZone
s will stay as they are.
Now our drag-and-drop feels much more responsive.
<div id="app"></div>
// context for the drag const DragContext = React.createContext(); // drag context component function Drag({ draggable = true, handleDrop, children }) { const [dragType, setDragType] = React.useState(null); // if multiple types of drag item const [dragItem, setDragItem] = React.useState(null); // the item being dragged const [isDragging, setIsDragging] = React.useState(null); const [drop, setDrop] = React.useState(null); // the active dropzone React.useEffect(() => { if (dragItem) { document.body.style.cursor = "grabbing"; } else { document.body.style.cursor = "default"; } }, [dragItem]); const dragStart = function(e, dragId, dragType) { e.stopPropagation(); e.dataTransfer.effectAllowed = 'move'; setDragItem(dragId); setDragType(dragType); }; const drag = function(e, dragId, dragType) { e.stopPropagation(); setIsDragging(true); }; const dragEnd = function(e) { setDragItem(null); setDragType(null); setIsDragging(false); setDrop(null); }; const onDrop = function(e) { e.preventDefault(); handleDrop({ dragItem, dragType, drop }); setDragItem(null); setDragType(null); setIsDragging(false); setDrop(null); }; return ( <DragContext.Provider value={{ draggable, dragItem, dragType, isDragging, dragStart, drag, dragEnd, drop, setDrop, onDrop }}> { typeof children === "function" ? children({ activeItem: dragItem, activeType: dragType, isDragging }) : children } </DragContext.Provider> ); }; // a draggable item Drag.DragItem = function({ as, dragId, dragType, ...props }) { const { draggable, dragStart, drag, dragEnd, dragItem } = React.useContext(DragContext); let Component = as || "div"; return <Component onDragStart={(e) => dragStart(e, dragId, dragType)} onDrag={drag} draggable={draggable} onDragEnd={dragEnd} {...props} />; }; // listens for drags over drop zones Drag.DropZone = function({ as, dropId, dropType, remember, children, style, ...props }) { const { dragItem, dragType, setDrop, drop, onDrop } = React.useContext(DragContext); function handleDragOver(e) { if (e.preventDefault) { e.preventDefault(); } return false; }; function handleLeave() { if (!remember) { setDrop(null); } }; let Component = as || "div"; return ( <Component onDragEnter={(e) => dragItem && dropType === dragType && setDrop(dropId)} onDragOver={handleDragOver} onDrop={onDrop} style={{position: "relative", ...style}} {...props}> { children } { drop === dropId && <div style={{position: "absolute", inset: "0px"}} onDragLeave={handleLeave}></div> } </Component> ); }; // if we need multiple dropzones Drag.DropZones = function({ dropType, prevId, nextId, split = "y", remember, children }) { const { dragType, isDragging } = React.useContext(DragContext); return ( <div style={{position: "relative"}}> { children } { dragType === dropType && isDragging && <div style={{position: "absolute", inset: "0px" }}> <Drag.DropZone dropId={prevId} style={ split === "x" ? { width: "50%" } : { height: "50%" }} dropType={dropType} remember={remember} /> <Drag.DropZone dropId={nextId} style={ split === "x" ? { width: "50%" } : { height: "50%" }} dropType={dropType} remember={remember} /> </div> } </div> ); }; // indicates where the drop will go when dragging over a dropzone Drag.DropGuide = function({ as, dropId, dropType, ...props }) { const { drop, dragType } = React.useContext(DragContext); let Component = as || "div"; return dragType === dropType && drop === dropId ? <Component {...props} /> : null; }; // the dummy Trello-style content const dummyData = [ { id: 1, name: "List 1", cards: [ { id: 1, title: "Card 1" }, { id: 2, title: "Card 2" }, { id: 3, title: "Card 3" }, { id: 4, title: "Card 4" }, { id: 5, title: "Card 5" }, ] }, { id: 2, name: "List 2", cards: [ { id: 6, title: "Card 6" }, { id: 7, title: "Card 7" }, { id: 8, title: "Card 8" }, ] }, ]; function Card({ title, description = "Drag and drop me!", dragItem }) { return ( <div className={`rounded-lg bg-white border border-gray-300 shadow-sm p-5 m-2${ dragItem ? " rotate-6" : ""}`}> <h3 className="font-bold text-lg my-1">{ title }</h3> <p>{ description }</p> </div> ); }; function List({ name, children }) { return ( <div className="rounded-xl bg-gray-100 p-2 mx-2 my-5 w-80 shrink-0 grow-0 shadow select-none"> <div className="px-6 py-1"> <h2 className="font-bold text-xl my-1">{ name }</h2> </div> { children } </div> ); }; // app component function App() { const [data, setData] = React.useState(dummyData); // handle a dropped item (TO DO) function handleDrop({ dragItem, dragType, drop }) { // do something }; return ( <div className="p-10 flex flex-col h-screen"> <h1 className="font-semibold text-3xl py-2">Trello-Style Drag & Drop</h1> <p>Let's drag some cards around!</p> <Drag handleDrop={handleDrop}> {({ activeItem, activeType, isDragging }) => ( <Drag.DropZone className="flex -mx-2 overflow-x-scroll h-full"> {data.map((list, listPosition) => { return ( <div key={list.id} className="flex flex-col h-full"> <List name={list.name}> {data[listPosition].cards.map((card, cardPosition) => { return ( <Drag.DropZones key={card.id} prevId={`${listPosition}-${cardPosition}`} nextId={`${listPosition}-${cardPosition+1}`} dropType="card" remember={true}> <Drag.DropGuide dropId={`${listPosition}-${cardPosition}`} className="rounded-lg bg-gray-200 h-24 m-2" dropType="card" /> <Drag.DragItem dragId={card.id} className={`cursor-pointer ${activeItem === card.id && activeType === "card" && isDragging ? "hidden" : "translate-x-0"}`} dragType="card"> <Card title={card.title} dragItem={activeItem === card.id && activeType === "card"} /> </Drag.DragItem> </Drag.DropZones> ); })} <Drag.DropZone dropId={`${listPosition}-${data[listPosition].cards.length}`} dropType="card" remember={true}> <Drag.DropGuide dropId={`${listPosition}-${data[listPosition].cards.length}`} className="rounded-lg bg-gray-200 h-24 m-2" dropType="card" /> </Drag.DropZone> </List> <Drag.DropZone dropId={`${listPosition}-${data[listPosition].cards.length}`} className="grow" dropType="card" remember={true} /> </div> ); })} </Drag.DropZone> )} </Drag> </div> ); }; // ======================================== // render the react app ReactDOM.render( <App />, document.getElementById('app') );
Handle the drop
Now we need to handle the drop. Remember that function we started earlier in our App
component?
// handle a dropped item (TO DO)
function handleDrop({ dragItem, dragType, drop }) {
// do something
};
It's time to do something!
We already passed this function to Drag
(<Drag handleDrop={handleDrop}>
), and it will get called when a drag-and-drop item gets dropped. It's called with three arguments, dragItem
, dragType
and drop
.
We've not added drag-and-drop to our lists yet, but let's handle dragType === "card"
.
// handle a dropped item
function handleDrop({ dragItem, dragType, drop }) {
if (dragType === "card") {
// get the drop position as numbers
let [newListPosition, newCardPosition] = drop.split("-").map((string) => parseInt(string));
// create a copy for the new data
let newData = structuredClone(data); // deep clone
// find the current positions
let oldCardPosition;
let oldListPosition = data.findIndex((list) => {
oldCardPosition = list.cards.findIndex((card) => card.id === dragItem);
return oldCardPosition >= 0;
});
// get the card
let card = data[oldListPosition].cards[oldCardPosition];
// if same array and current position before drop reduce drop position by one
if (newListPosition === oldListPosition && oldCardPosition < newCardPosition) {
newCardPosition--; // reduce by one
}
// remove the card from the old position
newData[oldListPosition].cards.splice(oldCardPosition, 1);
// put it in the new position
newData[newListPosition].cards.splice(newCardPosition, 0, card);
// update the state
setData(newData);
}
};
I've commented this function to show you how I've done it. You're handleDrop
function will depend on the structure of your data, the dragId
you assign to your items, and the dropId
you give to your drop zones.
In this function, I'm:
- finding the position of where the card was
- creating a copy of the state so I can make some changes
- removing the card from the old location
- inserting it at the new location
- updating the state with the new updated copy
And now we can drag-and-drop cards!
<div id="app"></div>
// context for the drag const DragContext = React.createContext(); // drag context component function Drag({ draggable = true, handleDrop, children }) { const [dragType, setDragType] = React.useState(null); // if multiple types of drag item const [dragItem, setDragItem] = React.useState(null); // the item being dragged const [isDragging, setIsDragging] = React.useState(null); const [drop, setDrop] = React.useState(null); // the active dropzone React.useEffect(() => { if (dragItem) { document.body.style.cursor = "grabbing"; } else { document.body.style.cursor = "default"; } }, [dragItem]); const dragStart = function(e, dragId, dragType) { e.stopPropagation(); e.dataTransfer.effectAllowed = 'move'; setDragItem(dragId); setDragType(dragType); }; const drag = function(e, dragId, dragType) { e.stopPropagation(); setIsDragging(true); }; const dragEnd = function(e) { setDragItem(null); setDragType(null); setIsDragging(false); setDrop(null); }; const onDrop = function(e) { e.preventDefault(); handleDrop({ dragItem, dragType, drop }); setDragItem(null); setDragType(null); setIsDragging(false); setDrop(null); }; return ( <DragContext.Provider value={{ draggable, dragItem, dragType, isDragging, dragStart, drag, dragEnd, drop, setDrop, onDrop }}> { typeof children === "function" ? children({ activeItem: dragItem, activeType: dragType, isDragging }) : children } </DragContext.Provider> ); }; // a draggable item Drag.DragItem = function({ as, dragId, dragType, ...props }) { const { draggable, dragStart, drag, dragEnd, dragItem } = React.useContext(DragContext); let Component = as || "div"; return <Component onDragStart={(e) => dragStart(e, dragId, dragType)} onDrag={drag} draggable={draggable} onDragEnd={dragEnd} {...props} />; }; // listens for drags over drop zones Drag.DropZone = function({ as, dropId, dropType, remember, children, style, ...props }) { const { dragItem, dragType, setDrop, drop, onDrop } = React.useContext(DragContext); function handleDragOver(e) { if (e.preventDefault) { e.preventDefault(); } return false; }; function handleLeave() { if (!remember) { setDrop(null); } }; let Component = as || "div"; return ( <Component onDragEnter={(e) => dragItem && dropType === dragType && setDrop(dropId)} onDragOver={handleDragOver} onDrop={onDrop} style={{position: "relative", ...style}} {...props}> { children } { drop === dropId && <div style={{position: "absolute", inset: "0px"}} onDragLeave={handleLeave}></div> } </Component> ); }; // if we need multiple dropzones Drag.DropZones = function({ dropType, prevId, nextId, split = "y", remember, children }) { const { dragType, isDragging } = React.useContext(DragContext); return ( <div style={{position: "relative"}}> { children } { dragType === dropType && isDragging && <div style={{position: "absolute", inset: "0px" }}> <Drag.DropZone dropId={prevId} style={ split === "x" ? { width: "50%" } : { height: "50%" }} dropType={dropType} remember={remember} /> <Drag.DropZone dropId={nextId} style={ split === "x" ? { width: "50%" } : { height: "50%" }} dropType={dropType} remember={remember} /> </div> } </div> ); }; // indicates where the drop will go when dragging over a dropzone Drag.DropGuide = function({ as, dropId, dropType, ...props }) { const { drop, dragType } = React.useContext(DragContext); let Component = as || "div"; return dragType === dropType && drop === dropId ? <Component {...props} /> : null; }; // the dummy Trello-style content const dummyData = [ { id: 1, name: "List 1", cards: [ { id: 1, title: "Card 1" }, { id: 2, title: "Card 2" }, { id: 3, title: "Card 3" }, { id: 4, title: "Card 4" }, { id: 5, title: "Card 5" }, ] }, { id: 2, name: "List 2", cards: [ { id: 6, title: "Card 6" }, { id: 7, title: "Card 7" }, { id: 8, title: "Card 8" }, ] }, ]; function Card({ title, description = "Drag and drop me!", dragItem }) { return ( <div className={`rounded-lg bg-white border border-gray-300 shadow-sm p-5 m-2${ dragItem ? " rotate-6" : ""}`}> <h3 className="font-bold text-lg my-1">{ title }</h3> <p>{ description }</p> </div> ); }; function List({ name, children }) { return ( <div className="rounded-xl bg-gray-100 p-2 mx-2 my-5 w-80 shrink-0 grow-0 shadow select-none"> <div className="px-6 py-1"> <h2 className="font-bold text-xl my-1">{ name }</h2> </div> { children } </div> ); }; // app component function App() { const [data, setData] = React.useState(dummyData); // handle a dropped item function handleDrop({ dragItem, dragType, drop }) { if (dragType === "card") { // get the drop position as numbers let [newListPosition, newCardPosition] = drop.split("-").map((string) => parseInt(string)); // create a copy for the new data let newData = structuredClone(data); // deep clone // find the current positions let oldCardPosition; let oldListPosition = data.findIndex((list) => { oldCardPosition = list.cards.findIndex((card) => card.id === dragItem); return oldCardPosition >= 0; }); // get the card let card = data[oldListPosition].cards[oldCardPosition]; // if same array and current position before drop reduce drop position by one if (newListPosition === oldListPosition && oldCardPosition < newCardPosition) { newCardPosition--; // reduce by one } // remove the card from the old position newData[oldListPosition].cards.splice(oldCardPosition, 1); // put it in the new position newData[newListPosition].cards.splice(newCardPosition, 0, card); // update the state setData(newData); } }; return ( <div className="p-10 flex flex-col h-screen"> <h1 className="font-semibold text-3xl py-2">Trello-Style Drag & Drop</h1> <p>Let's drag some cards around!</p> <Drag handleDrop={handleDrop}> {({ activeItem, activeType, isDragging }) => ( <Drag.DropZone className="flex -mx-2 overflow-x-scroll h-full"> {data.map((list, listPosition) => { return ( <div key={list.id} className="flex flex-col h-full"> <List name={list.name}> {data[listPosition].cards.map((card, cardPosition) => { return ( <Drag.DropZones key={card.id} prevId={`${listPosition}-${cardPosition}`} nextId={`${listPosition}-${cardPosition+1}`} dropType="card" remember={true}> <Drag.DropGuide dropId={`${listPosition}-${cardPosition}`} className="rounded-lg bg-gray-200 h-24 m-2" dropType="card" /> <Drag.DragItem dragId={card.id} className={`cursor-pointer ${activeItem === card.id && activeType === "card" && isDragging ? "hidden" : "translate-x-0"}`} dragType="card"> <Card title={card.title} dragItem={activeItem === card.id && activeType === "card"} /> </Drag.DragItem> </Drag.DropZones> ); })} <Drag.DropZone dropId={`${listPosition}-${data[listPosition].cards.length}`} dropType="card" remember={true}> <Drag.DropGuide dropId={`${listPosition}-${data[listPosition].cards.length}`} className="rounded-lg bg-gray-200 h-24 m-2" dropType="card" /> </Drag.DropZone> </List> <Drag.DropZone dropId={`${listPosition}-${data[listPosition].cards.length}`} className="grow" dropType="card" remember={true} /> </div> ); })} </Drag.DropZone> )} </Drag> </div> ); }; // ======================================== // render the react app ReactDOM.render( <App />, document.getElementById('app') );
Yay!
Dragging lists
You can also drag and drop lists in Trello!
To wire this up, we will apply most of what we did for cards to lists.
Let's start by wrapping our List
components in a DragItem
.
<Drag.DragItem dragId={list.id} className={`cursor-pointer ${activeItem === list.id && activeType === "list" && isDragging ? "hidden" : "translate-x-0"}`} dragType="list">
<List name={list.name} dragItem={activeItem === list.id && activeType === "list"}>
And we are passing the dragItem
prop to the List
component, so let's update the List
component to accept that - like we did on the Card
component.
function List({ name, dragItem, children }) {
return (
<div className={`rounded-xl bg-gray-100 p-2 mx-2 my-5 w-80 shrink-0 grow-0 shadow${ dragItem ? " rotate-6" : ""}`}>
<div className="px-6 py-1">
<h2 className="font-bold text-xl my-1">{ name }</h2>
</div>
{ children }
</div>
);
};
Nice - we can already drag our lists!
<div id="app"></div>
// context for the drag const DragContext = React.createContext(); // drag context component function Drag({ draggable = true, handleDrop, children }) { const [dragType, setDragType] = React.useState(null); // if multiple types of drag item const [dragItem, setDragItem] = React.useState(null); // the item being dragged const [isDragging, setIsDragging] = React.useState(null); const [drop, setDrop] = React.useState(null); // the active dropzone React.useEffect(() => { if (dragItem) { document.body.style.cursor = "grabbing"; } else { document.body.style.cursor = "default"; } }, [dragItem]); const dragStart = function(e, dragId, dragType) { e.stopPropagation(); e.dataTransfer.effectAllowed = 'move'; setDragItem(dragId); setDragType(dragType); }; const drag = function(e, dragId, dragType) { e.stopPropagation(); setIsDragging(true); }; const dragEnd = function(e) { setDragItem(null); setDragType(null); setIsDragging(false); setDrop(null); }; const onDrop = function(e) { e.preventDefault(); handleDrop({ dragItem, dragType, drop }); setDragItem(null); setDragType(null); setIsDragging(false); setDrop(null); }; return ( <DragContext.Provider value={{ draggable, dragItem, dragType, isDragging, dragStart, drag, dragEnd, drop, setDrop, onDrop }}> { typeof children === "function" ? children({ activeItem: dragItem, activeType: dragType, isDragging }) : children } </DragContext.Provider> ); }; // a draggable item Drag.DragItem = function({ as, dragId, dragType, ...props }) { const { draggable, dragStart, drag, dragEnd, dragItem } = React.useContext(DragContext); let Component = as || "div"; return <Component onDragStart={(e) => dragStart(e, dragId, dragType)} onDrag={drag} draggable={draggable} onDragEnd={dragEnd} {...props} />; }; // listens for drags over drop zones Drag.DropZone = function({ as, dropId, dropType, remember, children, style, ...props }) { const { dragItem, dragType, setDrop, drop, onDrop } = React.useContext(DragContext); function handleDragOver(e) { if (e.preventDefault) { e.preventDefault(); } return false; }; function handleLeave() { if (!remember) { setDrop(null); } }; let Component = as || "div"; return ( <Component onDragEnter={(e) => dragItem && dropType === dragType && setDrop(dropId)} onDragOver={handleDragOver} onDrop={onDrop} style={{position: "relative", ...style}} {...props}> { children } { drop === dropId && <div style={{position: "absolute", inset: "0px"}} onDragLeave={handleLeave}></div> } </Component> ); }; // if we need multiple dropzones Drag.DropZones = function({ dropType, prevId, nextId, split = "y", remember, children, ...props }) { const { dragType, isDragging } = React.useContext(DragContext); return ( <div style={{position: "relative"}} {...props}> { children } { dragType === dropType && isDragging && <div style={{position: "absolute", inset: "0px", display: "flex", flexDirection: split === "x" ? "row" : "column" }}> <Drag.DropZone dropId={prevId} style={{ width: "100%", height: "100%" }} dropType={dropType} remember={remember} /> <Drag.DropZone dropId={nextId} style={{ width: "100%", height: "100%" }} dropType={dropType} remember={remember} /> </div> } </div> ); }; // indicates where the drop will go when dragging over a dropzone Drag.DropGuide = function({ as, dropId, dropType, ...props }) { const { drop, dragType } = React.useContext(DragContext); let Component = as || "div"; return dragType === dropType && drop === dropId ? <Component {...props} /> : null; }; // the dummy Trello-style content const dummyData = [ { id: 1, name: "List 1", cards: [ { id: 1, title: "Card 1" }, { id: 2, title: "Card 2" }, { id: 3, title: "Card 3" }, { id: 4, title: "Card 4" }, { id: 5, title: "Card 5" }, ] }, { id: 2, name: "List 2", cards: [ { id: 6, title: "Card 6" }, { id: 7, title: "Card 7" }, { id: 8, title: "Card 8" }, ] }, ]; function Card({ title, description = "Drag and drop me!", dragItem }) { return ( <div className={`rounded-lg bg-white border border-gray-300 shadow-sm p-5 m-2${ dragItem ? " rotate-6" : ""}`}> <h3 className="font-bold text-lg my-1">{ title }</h3> <p>{ description }</p> </div> ); }; function List({ name, dragItem, children }) { return ( <div className={`rounded-xl bg-gray-100 p-2 mx-2 my-5 w-80 shrink-0 grow-0 shadow${ dragItem ? " rotate-6" : ""}`}> <div className="px-6 py-1"> <h2 className="font-bold text-xl my-1">{ name }</h2> </div> { children } </div> ); }; // app component function App() { const [data, setData] = React.useState(dummyData); // handle a dropped item function handleDrop({ dragItem, dragType, drop }) { if (dragType === "card") { // get the drop position as numbers let [newListPosition, newCardPosition] = drop.split("-").map((string) => parseInt(string)); // create a copy for the new data let newData = structuredClone(data); // deep clone // find the current positions let oldCardPosition; let oldListPosition = data.findIndex((list) => { oldCardPosition = list.cards.findIndex((card) => card.id === dragItem); return oldCardPosition >= 0; }); // get the card let card = data[oldListPosition].cards[oldCardPosition]; // if same array and current position before drop reduce drop position by one if (newListPosition === oldListPosition && oldCardPosition < newCardPosition) { newCardPosition--; // reduce by one } // remove the card from the old position newData[oldListPosition].cards.splice(oldCardPosition, 1); // put it in the new position newData[newListPosition].cards.splice(newCardPosition, 0, card); // update the state setData(newData); } }; return ( <div className="p-10 flex flex-col h-screen"> <h1 className="font-semibold text-3xl py-2">Trello-Style Drag & Drop</h1> <p>Let's drag some cards around!</p> <Drag handleDrop={handleDrop}> {({ activeItem, activeType, isDragging }) => ( <Drag.DropZone className="flex -mx-2 overflow-x-scroll h-full"> {data.map((list, listPosition) => { return ( <div key={list.id} className="flex flex-col h-full"> <Drag.DragItem dragId={list.id} className={`cursor-pointer ${activeItem === list.id && activeType === "list" && isDragging ? "hidden" : "translate-x-0"}`} dragType="list"> <List name={list.name} dragItem={activeItem === list.id && activeType === "list"}> {data[listPosition].cards.map((card, cardPosition) => { return ( <Drag.DropZones key={card.id} prevId={`${listPosition}-${cardPosition}`} nextId={`${listPosition}-${cardPosition+1}`} dropType="card" remember={true}> <Drag.DropGuide dropId={`${listPosition}-${cardPosition}`} className="rounded-lg bg-gray-200 h-24 m-2" dropType="card" /> <Drag.DragItem dragId={card.id} className={`cursor-pointer ${activeItem === card.id && activeType === "card" && isDragging ? "hidden" : "translate-x-0"}`} dragType="card"> <Card title={card.title} dragItem={activeItem === card.id && activeType === "card"} /> </Drag.DragItem> </Drag.DropZones> ); })} <Drag.DropZone dropId={`${listPosition}-${data[listPosition].cards.length}`} dropType="card" remember={true}> <Drag.DropGuide dropId={`${listPosition}-${data[listPosition].cards.length}`} className="rounded-lg bg-gray-200 h-24 m-2" dropType="card" /> </Drag.DropZone> </List> </Drag.DragItem> <Drag.DropZone dropId={`${listPosition}-${data[listPosition].cards.length}`} className="grow" dropType="card" remember={true} /> </div> ); })} </Drag.DropZone> )} </Drag> </div> ); }; // ======================================== // render the react app ReactDOM.render( <App />, document.getElementById('app') );
Dropping lists
Now we need to add some drop zones so that we can drop our lists. Like cards, our lists will be drop zones.
And like cards, our lists will be a double drop zone. The first half will drop the item before the list and the second half will drop the item after the list.
Because lists are dropped side to side (instead of above and below like cards), our DropZones
component needs to split on the "x" axis, instead of the default "y". We can pass the split
prop for that.
First, turn the div
that displays our List elements into a drop zone.
From this:
<div key={list.id} className="flex flex-col h-full">
To this:
<Drag.DropZones key={list.id} className="flex flex-col h-full" prevId={listPosition} nextId={listPosition+1} dropType="list" split="x" remember={true}>
Great!
Adding list drop guides
We need our drop guides to see where our lists will drop.
The DropGuide
needs to be outside the DropZones, because we want them to display as children of our main flex container so that they show next to our lists - not above or below our lists.
So we are going to add two DropGuides
for lists.
One before the list item.
We need to wrap our List in a React.Fragment
so that we can return two items, the list drop zone and the new drop guide.
We will also wrap our drop guides in drop zones (like we did for the cards) so we can drop items on the drop guides.
return (
<React.Fragment key={list.id}>
<Drag.DropZone dropId={listPosition} dropType="list" remember={true}>
<Drag.DropGuide dropId={listPosition} dropType="list" className="rounded-xl bg-gray-200 h-96 mx-2 my-5 w-80 shrink-0 grow-0" />
</Drag.DropZone>
<Drag.DropZones className="flex flex-col h-full" prevId={listPosition} nextId={listPosition+1} dropType="list" split="x" remember={true}>
{/* ... */}
</Drag.DropZones>
</React.Fragment>
);
Add another drop zone with a drop guide after the list - for when a list gets dragged to the end of the list.
<Drag handleDrop={handleDrop}>
{({ activeItem, activeType, isDragging }) => (
<Drag.DropZone className="flex -mx-2 overflow-x-scroll h-full">
{data.map((list, listPosition) => {
return (
//...
);
})}
<Drag.DropZone dropId={data.length} dropType="list" remember={true}>
<Drag.DropGuide dropId={data.length} dropType="list" className="rounded-xl bg-gray-200 h-96 mx-2 my-5 w-80 shrink-0 grow-0" />
</Drag.DropZone>
</Drag.DropZone>
)}
</Drag>
<div id="app"></div>
// context for the drag const DragContext = React.createContext(); // drag context component function Drag({ draggable = true, handleDrop, children }) { const [dragType, setDragType] = React.useState(null); // if multiple types of drag item const [dragItem, setDragItem] = React.useState(null); // the item being dragged const [isDragging, setIsDragging] = React.useState(null); const [drop, setDrop] = React.useState(null); // the active dropzone React.useEffect(() => { if (dragItem) { document.body.style.cursor = "grabbing"; } else { document.body.style.cursor = "default"; } }, [dragItem]); const dragStart = function(e, dragId, dragType) { e.stopPropagation(); e.dataTransfer.effectAllowed = 'move'; setDragItem(dragId); setDragType(dragType); }; const drag = function(e, dragId, dragType) { e.stopPropagation(); setIsDragging(true); }; const dragEnd = function(e) { setDragItem(null); setDragType(null); setIsDragging(false); setDrop(null); }; const onDrop = function(e) { e.preventDefault(); handleDrop({ dragItem, dragType, drop }); setDragItem(null); setDragType(null); setIsDragging(false); setDrop(null); }; return ( <DragContext.Provider value={{ draggable, dragItem, dragType, isDragging, dragStart, drag, dragEnd, drop, setDrop, onDrop }}> { typeof children === "function" ? children({ activeItem: dragItem, activeType: dragType, isDragging }) : children } </DragContext.Provider> ); }; // a draggable item Drag.DragItem = function({ as, dragId, dragType, ...props }) { const { draggable, dragStart, drag, dragEnd, dragItem } = React.useContext(DragContext); let Component = as || "div"; return <Component onDragStart={(e) => dragStart(e, dragId, dragType)} onDrag={drag} draggable={draggable} onDragEnd={dragEnd} {...props} />; }; // listens for drags over drop zones Drag.DropZone = function({ as, dropId, dropType, remember, children, style, ...props }) { const { dragItem, dragType, setDrop, drop, onDrop } = React.useContext(DragContext); function handleDragOver(e) { if (e.preventDefault) { e.preventDefault(); } return false; }; function handleLeave() { if (!remember) { setDrop(null); } }; let Component = as || "div"; return ( <Component onDragEnter={(e) => dragItem && dropType === dragType && setDrop(dropId)} onDragOver={handleDragOver} onDrop={onDrop} style={{position: "relative", ...style}} {...props}> { children } { drop === dropId && <div style={{position: "absolute", inset: "0px"}} onDragLeave={handleLeave}></div> } </Component> ); }; // if we need multiple dropzones Drag.DropZones = function({ dropType, prevId, nextId, split = "y", remember, children, ...props }) { const { dragType, isDragging } = React.useContext(DragContext); return ( <div style={{position: "relative"}} {...props}> { children } { dragType === dropType && isDragging && <div style={{position: "absolute", inset: "0px", display: "flex", flexDirection: split === "x" ? "row" : "column" }}> <Drag.DropZone dropId={prevId} style={{ width: "100%", height: "100%" }} dropType={dropType} remember={remember} /> <Drag.DropZone dropId={nextId} style={{ width: "100%", height: "100%" }} dropType={dropType} remember={remember} /> </div> } </div> ); }; // indicates where the drop will go when dragging over a dropzone Drag.DropGuide = function({ as, dropId, dropType, ...props }) { const { drop, dragType } = React.useContext(DragContext); let Component = as || "div"; return dragType === dropType && drop === dropId ? <Component {...props} /> : null; }; // the dummy Trello-style content const dummyData = [ { id: 1, name: "List 1", cards: [ { id: 1, title: "Card 1" }, { id: 2, title: "Card 2" }, { id: 3, title: "Card 3" }, { id: 4, title: "Card 4" }, { id: 5, title: "Card 5" }, ] }, { id: 2, name: "List 2", cards: [ { id: 6, title: "Card 6" }, { id: 7, title: "Card 7" }, { id: 8, title: "Card 8" }, ] }, ]; function Card({ title, description = "Drag and drop me!", dragItem }) { return ( <div className={`rounded-lg bg-white border border-gray-300 shadow-sm p-5 m-2${ dragItem ? " rotate-6" : ""}`}> <h3 className="font-bold text-lg my-1">{ title }</h3> <p>{ description }</p> </div> ); }; function List({ name, dragItem, children }) { return ( <div className={`rounded-xl bg-gray-100 p-2 mx-2 my-5 w-80 shrink-0 grow-0 shadow${ dragItem ? " rotate-6" : ""}`}> <div className="px-6 py-1"> <h2 className="font-bold text-xl my-1">{ name }</h2> </div> { children } </div> ); }; // app component function App() { const [data, setData] = React.useState(dummyData); // handle a dropped item function handleDrop({ dragItem, dragType, drop }) { if (dragType === "card") { // get the drop position as numbers let [newListPosition, newCardPosition] = drop.split("-").map((string) => parseInt(string)); // create a copy for the new data let newData = structuredClone(data); // deep clone // find the current positions let oldCardPosition; let oldListPosition = data.findIndex((list) => { oldCardPosition = list.cards.findIndex((card) => card.id === dragItem); return oldCardPosition >= 0; }); // get the card let card = data[oldListPosition].cards[oldCardPosition]; // if same array and current position before drop reduce drop position by one if (newListPosition === oldListPosition && oldCardPosition < newCardPosition) { newCardPosition--; // reduce by one } // remove the card from the old position newData[oldListPosition].cards.splice(oldCardPosition, 1); // put it in the new position newData[newListPosition].cards.splice(newCardPosition, 0, card); // update the state setData(newData); } }; return ( <div className="p-10 flex flex-col h-screen"> <h1 className="font-semibold text-3xl py-2">Trello-Style Drag & Drop</h1> <p>Let's drag some cards around!</p> <Drag handleDrop={handleDrop}> {({ activeItem, activeType, isDragging }) => ( <Drag.DropZone className="flex -mx-2 overflow-x-scroll h-full"> {data.map((list, listPosition) => { return ( <React.Fragment key={list.id}> <Drag.DropZone dropId={listPosition} dropType="list" remember={true}> <Drag.DropGuide dropId={listPosition} dropType="list" className="rounded-xl bg-gray-200 h-96 mx-2 my-5 w-80 shrink-0 grow-0" /> </Drag.DropZone> <Drag.DropZones className="flex flex-col h-full" prevId={listPosition} nextId={listPosition+1} dropType="list" split="x" remember={true}> <Drag.DragItem dragId={list.id} className={`cursor-pointer ${activeItem === list.id && activeType === "list" && isDragging ? "hidden" : "translate-x-0"}`} dragType="list"> <List name={list.name} dragItem={activeItem === list.id && activeType === "list"}> {data[listPosition].cards.map((card, cardPosition) => { return ( <Drag.DropZones key={card.id} prevId={`${listPosition}-${cardPosition}`} nextId={`${listPosition}-${cardPosition+1}`} dropType="card" remember={true}> <Drag.DropGuide dropId={`${listPosition}-${cardPosition}`} className="rounded-lg bg-gray-200 h-24 m-2" dropType="card" /> <Drag.DragItem dragId={card.id} className={`cursor-pointer ${activeItem === card.id && activeType === "card" && isDragging ? "hidden" : "translate-x-0"}`} dragType="card"> <Card title={card.title} dragItem={activeItem === card.id && activeType === "card"} /> </Drag.DragItem> </Drag.DropZones> ); })} <Drag.DropZone dropId={`${listPosition}-${data[listPosition].cards.length}`} dropType="card" remember={true}> <Drag.DropGuide dropId={`${listPosition}-${data[listPosition].cards.length}`} className="rounded-lg bg-gray-200 h-24 m-2" dropType="card" /> </Drag.DropZone> </List> </Drag.DragItem> <Drag.DropZone dropId={`${listPosition}-${data[listPosition].cards.length}`} className="grow" dropType="card" remember={true} /> </Drag.DropZones> </React.Fragment> ); })} <Drag.DropZone dropId={data.length} dropType="list" remember={true}> <Drag.DropGuide dropId={data.length} dropType="list" className="rounded-xl bg-gray-200 h-96 mx-2 my-5 w-80 shrink-0 grow-0" /> </Drag.DropZone> </Drag.DropZone> )} </Drag> </div> ); }; // ======================================== // render the react app ReactDOM.render( <App />, document.getElementById('app') );
Super - nearly finished!
Handle the list drop
Now we have the drop guides and drop zones in place for lists, let's handle the drop and get our lists moving.
Lets update handleDrop
.
// handle a dropped item
function handleDrop({ dragItem, dragType, drop }) {
if (dragType === "card") {
// ...
} else if (dragType === "list") {
let newListPosition = drop;
let oldListPosition = data.findIndex((list) => list.id === dragItem);
// create a copy for the new data
let newData = structuredClone(data); // deep clone
// get the list
let list = data[oldListPosition];
// if current position before drop reduce drop position by one
if (oldListPosition < newListPosition) {
newListPosition--; // reduce by one
}
// remove list from the old position
newData.splice(oldListPosition, 1);
// put it in the new position
newData.splice(newListPosition, 0, list);
// update the state
setData(newData);
}
};
I've commented this out, it's very similar to how we moved cards - but for lists.
Now the lists move too!
<div id="app"></div>
// context for the drag const DragContext = React.createContext(); // drag context component function Drag({ draggable = true, handleDrop, children }) { const [dragType, setDragType] = React.useState(null); // if multiple types of drag item const [dragItem, setDragItem] = React.useState(null); // the item being dragged const [isDragging, setIsDragging] = React.useState(null); const [drop, setDrop] = React.useState(null); // the active dropzone React.useEffect(() => { if (dragItem) { document.body.style.cursor = "grabbing"; } else { document.body.style.cursor = "default"; } }, [dragItem]); const dragStart = function(e, dragId, dragType) { e.stopPropagation(); e.dataTransfer.effectAllowed = 'move'; setDragItem(dragId); setDragType(dragType); }; const drag = function(e, dragId, dragType) { e.stopPropagation(); setIsDragging(true); }; const dragEnd = function(e) { setDragItem(null); setDragType(null); setIsDragging(false); setDrop(null); }; const onDrop = function(e) { e.preventDefault(); handleDrop({ dragItem, dragType, drop }); setDragItem(null); setDragType(null); setIsDragging(false); setDrop(null); }; return ( <DragContext.Provider value={{ draggable, dragItem, dragType, isDragging, dragStart, drag, dragEnd, drop, setDrop, onDrop }}> { typeof children === "function" ? children({ activeItem: dragItem, activeType: dragType, isDragging }) : children } </DragContext.Provider> ); }; // a draggable item Drag.DragItem = function({ as, dragId, dragType, ...props }) { const { draggable, dragStart, drag, dragEnd, dragItem } = React.useContext(DragContext); let Component = as || "div"; return <Component onDragStart={(e) => dragStart(e, dragId, dragType)} onDrag={drag} draggable={draggable} onDragEnd={dragEnd} {...props} />; }; // listens for drags over drop zones Drag.DropZone = function({ as, dropId, dropType, remember, children, style, ...props }) { const { dragItem, dragType, setDrop, drop, onDrop } = React.useContext(DragContext); function handleDragOver(e) { if (e.preventDefault) { e.preventDefault(); } return false; }; function handleLeave() { if (!remember) { setDrop(null); } }; let Component = as || "div"; return ( <Component onDragEnter={(e) => dragItem && dropType === dragType && setDrop(dropId)} onDragOver={handleDragOver} onDrop={onDrop} style={{position: "relative", ...style}} {...props}> { children } { drop === dropId && <div style={{position: "absolute", inset: "0px"}} onDragLeave={handleLeave}></div> } </Component> ); }; // if we need multiple dropzones Drag.DropZones = function({ dropType, prevId, nextId, split = "y", remember, children, ...props }) { const { dragType, isDragging } = React.useContext(DragContext); return ( <div style={{position: "relative"}} {...props}> { children } { dragType === dropType && isDragging && <div style={{position: "absolute", inset: "0px", display: "flex", flexDirection: split === "x" ? "row" : "column" }}> <Drag.DropZone dropId={prevId} style={{ width: "100%", height: "100%" }} dropType={dropType} remember={remember} /> <Drag.DropZone dropId={nextId} style={{ width: "100%", height: "100%" }} dropType={dropType} remember={remember} /> </div> } </div> ); }; // indicates where the drop will go when dragging over a dropzone Drag.DropGuide = function({ as, dropId, dropType, ...props }) { const { drop, dragType } = React.useContext(DragContext); let Component = as || "div"; return dragType === dropType && drop === dropId ? <Component {...props} /> : null; }; // the dummy Trello-style content const dummyData = [ { id: 1, name: "List 1", cards: [ { id: 1, title: "Card 1" }, { id: 2, title: "Card 2" }, { id: 3, title: "Card 3" }, { id: 4, title: "Card 4" }, { id: 5, title: "Card 5" }, ] }, { id: 2, name: "List 2", cards: [ { id: 6, title: "Card 6" }, { id: 7, title: "Card 7" }, { id: 8, title: "Card 8" }, ] }, ]; function Card({ title, description = "Drag and drop me!", dragItem }) { return ( <div className={`rounded-lg bg-white border border-gray-300 shadow-sm p-5 m-2${ dragItem ? " rotate-6" : ""}`}> <h3 className="font-bold text-lg my-1">{ title }</h3> <p>{ description }</p> </div> ); }; function List({ name, dragItem, children }) { return ( <div className={`rounded-xl bg-gray-100 p-2 mx-2 my-5 w-80 shrink-0 grow-0 shadow${ dragItem ? " rotate-6" : ""}`}> <div className="px-6 py-1"> <h2 className="font-bold text-xl my-1">{ name }</h2> </div> { children } </div> ); }; // app component function App() { const [data, setData] = React.useState(dummyData); // handle a dropped item function handleDrop({ dragItem, dragType, drop }) { if (dragType === "card") { // get the drop position as numbers let [newListPosition, newCardPosition] = drop.split("-").map((string) => parseInt(string)); // create a copy for the new data let newData = structuredClone(data); // deep clone // find the current positions let oldCardPosition; let oldListPosition = data.findIndex((list) => { oldCardPosition = list.cards.findIndex((card) => card.id === dragItem); return oldCardPosition >= 0; }); // get the card let card = data[oldListPosition].cards[oldCardPosition]; // if same array and current position before drop reduce drop position by one if (newListPosition === oldListPosition && oldCardPosition < newCardPosition) { newCardPosition--; // reduce by one } // remove the card from the old position newData[oldListPosition].cards.splice(oldCardPosition, 1); // put it in the new position newData[newListPosition].cards.splice(newCardPosition, 0, card); // update the state setData(newData); } else if (dragType === "list") { let newListPosition = drop; let oldListPosition = data.findIndex((list) => list.id === dragItem); // create a copy for the new data let newData = structuredClone(data); // deep clone // get the list let list = data[oldListPosition]; // if current position before drop reduce drop position by one if (oldListPosition < newListPosition) { newListPosition--; // reduce by one } // remove list from the old position newData.splice(oldListPosition, 1); // put it in the new position newData.splice(newListPosition, 0, list); // update the state setData(newData); } }; return ( <div className="p-10 flex flex-col h-screen"> <h1 className="font-semibold text-3xl py-2">Trello-Style Drag & Drop</h1> <p>Let's drag some cards around!</p> <Drag handleDrop={handleDrop}> {({ activeItem, activeType, isDragging }) => ( <Drag.DropZone className="flex -mx-2 overflow-x-scroll h-full"> {data.map((list, listPosition) => { return ( <React.Fragment key={list.id}> <Drag.DropZone dropId={listPosition} dropType="list" remember={true}> <Drag.DropGuide dropId={listPosition} dropType="list" className="rounded-xl bg-gray-200 h-96 mx-2 my-5 w-80 shrink-0 grow-0" /> </Drag.DropZone> <Drag.DropZones className="flex flex-col h-full" prevId={listPosition} nextId={listPosition+1} dropType="list" split="x" remember={true}> <Drag.DragItem dragId={list.id} className={`cursor-pointer ${activeItem === list.id && activeType === "list" && isDragging ? "hidden" : "translate-x-0"}`} dragType="list"> <List name={list.name} dragItem={activeItem === list.id && activeType === "list"}> {data[listPosition].cards.map((card, cardPosition) => { return ( <Drag.DropZones key={card.id} prevId={`${listPosition}-${cardPosition}`} nextId={`${listPosition}-${cardPosition+1}`} dropType="card" remember={true}> <Drag.DropGuide dropId={`${listPosition}-${cardPosition}`} className="rounded-lg bg-gray-200 h-24 m-2" dropType="card" /> <Drag.DragItem dragId={card.id} className={`cursor-pointer ${activeItem === card.id && activeType === "card" && isDragging ? "hidden" : "translate-x-0"}`} dragType="card"> <Card title={card.title} dragItem={activeItem === card.id && activeType === "card"} /> </Drag.DragItem> </Drag.DropZones> ); })} <Drag.DropZone dropId={`${listPosition}-${data[listPosition].cards.length}`} dropType="card" remember={true}> <Drag.DropGuide dropId={`${listPosition}-${data[listPosition].cards.length}`} className="rounded-lg bg-gray-200 h-24 m-2" dropType="card" /> </Drag.DropZone> </List> </Drag.DragItem> <Drag.DropZone dropId={`${listPosition}-${data[listPosition].cards.length}`} className="grow" dropType="card" remember={true} /> </Drag.DropZones> </React.Fragment> ); })} <Drag.DropZone dropId={data.length} dropType="list" remember={true}> <Drag.DropGuide dropId={data.length} dropType="list" className="rounded-xl bg-gray-200 h-96 mx-2 my-5 w-80 shrink-0 grow-0" /> </Drag.DropZone> </Drag.DropZone> )} </Drag> </div> ); }; // ======================================== // render the react app ReactDOM.render( <App />, document.getElementById('app') );
👏 And that's it for our Trello-like drag-and-drop board - I hope you enjoyed building it with me!