Tutorial

Writing a drag and drop sortable list component šŸ‰

Why it's a terrible idea and why you should do it

This tutorial is mostly just code snippets and isn’t super mobile-friendly. You can absolutely still read it on your phone, but it’s much easier on a bigger screen.

Problem

In a previous job, the product I worked on had a form builder (similar to Google Forms) where you could rearrange questions using drag and drop functionality. Questions could also be nested into sections to help organize the form. Dragging the items was mostly easy, though dropping them where you wanted was often a challenge. Sometimes you'd have to try multiple times to get the item to drop where you wanted. The experience felt clunky, unpredictable, and frustrating. I didn't understand why this felt so bad at first, but after building my own drag and drop functionality, I think I have a better understanding why.

Let's write some code to better understand what makes a good drag and drop experience.

Step 1: Rendering a list

First, we'll render a list of items. We'll create a <ListItem /> component and loop over an array of colors, rendering one component for each color.

Example 1: Creating a basic list

Next, let's make them draggable so that we can actually move them around the screen.

Step 2: Making the list items draggable

To make them draggable we'll need to do 2 things:

  1. Keep track which item is being dragged
  2. Update the position of the dragged item using the mouse position

To keep track of which item is dragged, we need to know when the user started and stopped clicking. mousedown will let us know which item we intend to move and will be the start of the drag event. We can add that directly to the component and have it control some internal state. mouseup will let us know when we're done dragging and is our trigger for the drop event. Seems natural that we'd also add that to this component so let's do that for now.

I added a border color change so it's clear if we're correctly tracking the item we want to drag. Try out the example below and see if you can get the list items to show a green border when the mouse is down.

Example 2: Making them draggable pt. 1

You'll notice that clicking on an item will add the green border and if you don't move the mouse and release the click, the border will go away. But there's a problem. If you click on an item and then move your mouse outside that item and release the click, the green border doesn't go away. That's because the mouseup event of the original item isn't firing.

To get the desired functionality here, we can lift some of the state and event handling out of the ListItem component. This will allow us to listen to mouseup on the entire window to more predictably stop the dragging event. For now, we'll use a context provider to do this.

In our context provider we'll set up two hooks:

  1. A useState hook that tracks the ID of the dragged item
  2. A useEffect hook to add our our mouseup event listener to the window

And then we'll make a few changes to our <ListItem />:

  1. Instead of setIsDragging, we'll use the setDraggingItemId method from our DragDropContext and pass the ListItem's color as our unique ID
  2. We'll make isDragging a boolean that checks if draggingItemId is the same as the color prop of the list item
Example 3: Making them draggable pt. 2

Our mouseup and mousedown events are now correctly being tracked no matter where we start or end the click and drag event. Nice!

Next, we need to actually move the item we're dragging. We'll do this by adding another event listener to the DragDropProvider for mousemove and using the mouse coordinates to add a CSS transform to the dragged item. We'll add some state to save the initial mouse position so as we move the mouse, we can calculate the delta between the initial mouse position and the current mouse position. Then we'll just apply that delta as a translation to the dragged item. Try it out below and click and drag a few different items around.

Example 4: Making them draggable pt. 3

Now we're cooking with gas! Or fire. Whatever the saying is. We're cooking with something.

You might have noticed that repeatedly dragging and dropping the same item will cause it to jump around. We'll fix that in the next step. We're dragging, but next we need to talk about dropping. Specifically, we need to talk about drop indicators.

Step 3: Adding a drop indicator

As you drag an item around the screen, there are 2 common ways to indicate where you're going to drop the item currently being dragged.

Placeholder
Insert bar
  1. Placeholder: An empty space or ghost version of the dragged item
    • As you drag the item around the screen, the placeholder moves around and rearranges itself with the other items in the list to show you where the dragging item will be placed.
    • When you let go of the mouse, the dragged item replaces the placeholder.
    • Great for immmediately visualizing the new position of the dragged item.
  2. Insert bar: Basically, a thin line between the items
    • As you drag the item, the bar moves around and nestles between the items in the list, showing you where the item will be inserted if you let go of your mouse.
    • When you let go of the mouse, the dragged item is inserted where the bar was.
    • Great for keeping the current list in tact and not shifting items around as you drag.

Let's try implementing the placeholder style first.

Drop indicator #1: Placeholder

To implement a placeholder drop indicator, we'll add a hidden div to our rendered list. When we start dragging something, we'll insert this div into the DOM in the place of the dragged item and match the dimensions of the dragged item. At the same time, we'll change the dragged item's positioning to position: fixed to remove it from the flow of content in the DOM. The result is that when we click an item, nothing will appear to happen, but if you move the mouse slightly, you'll see the placeholder div behind the dragged item.

Example 5: Adding a drop indicator: Placeholder pt. 1

So now we have a placeholder that is holding the place of the dragged item, but the effect breaks down when you let go of the mouse. The dragged item just floats around in the ether. Let's fix that quickly.

We already have a mouseup event that we're using to stop the drag event so let's add a few things to it to handle the drop.

With a placeholder drop indicator, when we're done dragging, we just want to replace the placeholder with the dragged item. To break that into steps we need to:

  1. Clear the transform styling of the dragged item
  2. Insert the dragged item at the same index as the placeholder
  3. Hide the placeholder

It's essentially just the reverse of what we did when we started dragging.

Example 6: Adding a drop indicator: Placeholder pt. 2

Nice! Now we have a placeholder that sneakily swaps with the dragged item when we start and finish dragging. The effect of the placeholder showing up behind any item we're dragging seems to be working! We're certainly dragging and we're technically dropping, but we're missing a very key part of the drag and drop experience. We need to be able to drag items and rearrange them.

We need to talk about swap criteria.

Swap criteria: a short detour

If I want to take ā€œItem redā€ and swap it with ā€œItem blueā€, I should just be able to drag "Item red" below "Item blue" and the two items should swap places. But how does it know when to swap?

There are 2 common ways of defining when items should swap. I call them ā€œswap criteriaā€.

Cursor-based
Edge-based
  1. Cursor-based
    • When dragging an item, use the cursor to determine when and if things should swap.
    • For example, if you want to swap an item with the one below it, you'd click and hold to start the drag event and you'd start moving the item down. When the cursor goes below the vertical midpoint of the neighboring item, we'd move the item placeholder below the neighboring item.
  2. Edge-based
    • When dragging an item down, use the edges of the item to determine when and if things should swap.
      • For example, if you want to swap an item with the one below it, you'd click and hold to start the drag event and you'd start moving the item down. When the bottom edge of the dragged item passes the vertical midpoint of the hovering item, we'd move the item placeholder below the neighboring item.

Let's get a feel for the difference between these two approaches by implementing them both.

Cursor-based

Implementing cursor-based swap criteria is fairly straightforward. In the mousemove handler we first need to figure out what item the cursor is currently on top of when we're dragging – we call this hoveringElement . To do this, we'll can use document.elementFromPoint.

Then we get the y midpoint of the hovering element and check the position of the cursor relative to the y midpoint. If we're below the midpoint, we should move the placeholder below the hoveringElement. If we're above the midpoint, we should move the placeholder above the hoveringElement. To move the placeholder, we'll use a super simple swapElements utility function.

Example 7: Adding a drop indicator: Placeholder pt. 3

Not bad! In fact, you might even say it's good. At this point, we do have a sortable drag and drop list. But it could be much better. Let's look a little closer at this interaction pattern.

We've established that when the cursor passes the midpoint of another list item, we swap the placeholder with that item. This feels pretty intuitive as long as you're starting the drag by clicking on an item around its y midpoint. But go up to the previous example and try grabbing ā€œItem redā€ near the very top of the item and then try swapping it with ā€œItem blueā€. You'll notice that you have to drag the item quite far before it swaps with ā€œItem blueā€. In fact, by the time the cursor has hit the midpoint of ā€œItem blueā€, we're already overlapping with ā€œItem greenā€ too. Now try grabbing ā€œItem redā€ at its bottom border and swapping it with ā€œItem blueā€. It swaps much quicker.

What we're observing is that the required distance that the cursor needs to travel can vary widely and it depends on where you grab the dragged item. If every list item is 50px tall, grabbing ā€œItem redā€ at the top border and dragging it below ā€œItem blueā€ requires the cursor to travel nearly 75px (50px for the dragged item placeholder + 25px for half the height of the neighboring item), whereas grabbing ā€œItem redā€ from the bottom border only requires the cursor to travel 25px (half the height of the neighboring item). That's a range of 0.5 - 1.5x the height of the list item. In this example the list items are relatively short, so this isn't terribly impactful, but with taller list items, this required distance that the cursor must travel is felt much more.

Quick note: using drag handles on the draggable items can make this feel more intuitive since it removes a lot of the variability caused by grabbing draggable items in different places. That said, the height of the items can still negatively impact the experience and using drag handles doesn't automatically mean you should use cursor-based swap criteria.

Edge-based

The edge-based approach is quite similar to cursor-based, but rather than using the cursor position, we'll use the y positions of the top and bottom edges of the dragged item. Instead of a single hoveringItem that we used for cursor-based, we'll keep track of 2 items: topIntersecting and bottomIntersecting.

If the top edge of the dragged item passes a certain threshold of the topIntersecting item, we'll swap the placeholder with that item. The same follows for the bottom edge and bottomIntersecting item.

Example 8: Adding a drop indicator: Placeholder pt. 4

This is nice. Notice how it's much easier to swap 2 items in the list regardless of where you grab the dragged item. This is because the required distance travelled by the cursor is consistently half the height of the item. You can grab ā€œItem redā€ at the very top edge and – at most – you'll only need to drag it 25px before the bottom edge hits the midpoint of ā€œItem blueā€. Wahoo.

That's a quick detour on swap criteria. Now that we have the placeholder drop indicator working and we've identified some ways to determine how elements should swap, let's talk about the 2nd type of drop indicator.

Drop indicator #2: Insert bar

The insert bar drop indicator is fairly simple, though it has its own areas of complexity.

For starters, when you start dragging an item, there should be an inactive version of it in the original location so that the layout doesn't shift. To do this, we'll clone the selected list item during the first click event and then drag around a lowered opacity version of the list item.

To position the drop indicator bar, we'll reuse the cursor-based swap criteria logic from the placeholder drop indicator to position the placeholder element. In terms of visual stacking we want the bar to be on top of the list items, but the dragging item to be on top of the bar.

Example 9: Adding a drop indicator: Bar pt. 1

There are a few benefits to the drop indicator bar:

  • It makes dragging large items less cumbersome since you're not shifting a placeholder around the screen, nor do you have to worry about the size of the dragged item.
  • Since only the insert bar is moving around the screen, it's not as much motion for the user, making it a bit less overwhelming.
  • It makes nested drag and drop possible (which we'll explore in Step 5).

While both styles of drop indicator have their benefits, the drop indicator bar is a bit more fitting for this use case. For the rest of this article, we'll use the drop indicator bar.

Step 4: Making it more library-like

The implementation up to this point has been fairly context-specific. The code isn't really reusable (unless you're using the same list items and drop indicators). We were writing an opinionated little sortable color list. Now it's time to make this look a bit more like a library you could re-use.

Separating the display logic

We've been working on a few components up to this point:

  • <List/> is the container of the list of ListItems. It's the parent container for all draggable items and our placeholder and bar elements.
  • <ListItem/> is the draggable items in our list. They communicate with our DragDropProvider and have event handlers to track the start of a drag event.
  • <DragDropProvider/> is the component and context provider responsible for event handling and doing all of the sorting logic.

To make this into more of a library, we're going to do a few things (inspired by react beautiful dnd):

  1. Create a <Draggable /> component to wrap our <ListItem />
    1. This will separate the drag and drop logic from the content of the draggable item which will make it much more flexible to use in the future.
  2. Create a <Droppable /> component to use in place of our <List /> component
    1. This will let us encapsulate the placeholder and bar elements so the consumer doesn't have to define these elements or care about them.
  3. Update the DragDropProvider to define the active placeholder as the placeholder inside the current draggable.

If all works well, the refactor should give us the exact same thing as before.

Let's start with creating the <Draggable/> and <Droppable/> components.

Example 10: Converting to library level code pt. 1

Looks good! And now since we've separated the drag and drop logic from the display logic, we can use the same library-level components to create completely different styled lists.

Example 11: Converting to library level code pt. 2

We can even have multiple droppable lists on the page – although be warned, it has a gnarly bug if you try and drag an item back and forth between lists. Try it out and see what happens.

Example 12: Converting to library level code pt. 3

Any guesses on what's happening?

Let's dive into the bug and fix it before we continue making these components more library-like.

Supporting multiple droppables

The cause of the bug above has to do with our criteria for what's considered the ā€œactive placeholderā€. When we drag an item over another item in a different list, we should expect the parent droppable of the intersecting item to show the placeholder in its list. So, we'll update the placeholder showing logic in 2 ways:

  1. When clicking on an item to start dragging it, we'll immediately show the placeholder of the dragging item's parent droppable.
  2. When moving an item around the screen, we'll look at the the intersecting item's parent droppable to determine which placeholders to show.

Since the placeholder is no longer guaranteed to be in the parent of the item we're dragging, we'll need to start tracking the ID of the droppable we're currently on top of and making sure to update the drop event to insert the dragging item based on the tracked droppable ID.

Example 13: Converting to library level code pt. 4

Event callbacks

The last step to making this library-like is to add event callbacks to the drop event. In a real world application, we don't want to just move items around the DOM and call it a day. You'd likely be updating the ordering of a list of items and then expecting your changes to be saved. So with every drag-and-drop action, we should be able to do things like send an API call to update the data in the database and re-render our list on the frontend to reflect the changes stored in the database.

To be able to decode the intent of the drag-and-drop action, we'll include the following information in our event callback:

  • The ID of the item being dragged
  • The starting index of the dragged item (within its original droppable at the start of the drag)
  • The ID of the droppable we started in
  • The index of the placeholder (within the droppable where it was dropped)
  • The ID of the droppable we ended in

This way we can tell exactly which item to move and where to insert it. Open the console in the example below and check out the callback events.

Example 14: Drop event callbacks

With these event callbacks, we'll be able to send off any API calls and update react state that's used to render these UI items. Bonus points if you can optimisticall update the UI before the API call returns.

Step 5: Nested droppables

The cool thing is that with insert bar drop indicators, nested droppables pretty much just workā„¢. Here's an example that displays a list of items, some of which have children that also render a list of items. The only change we'll need to make is to add e.stopPropagation() to our mousedown event. This will prevent multiple events from triggering when clicking on a draggable item that has a parent item, which is also draggable.

Example 15: Nested droppables pt. 1

At this point, we have a functioning nested drag and drop sortable list with a reusable interface! Pat yourself on the back, you did it.

Possible improvements

There's a long list of improvements we could make to this. I'll try to keep it brief:

  • Animation. In my exploration of this library, animations work better for certain drop indicator styles (like the placeholder), but making use of a library like Motion or GSAP can make tweening animations incredibly simple and keep them performant.
  • Keyboard support. For accessibility reasons, it'd be great if you could use the keyboard to move items around the list. You could, for example, focus on a draggable item, press the spacebar to pick it up, then use the arrow keys to move it around the page. As you move it, the placeholders would appear as expected and maybe you could press the spacebar again to drop it.
  • Autoscrolling. When you're dragging an item within a scrollable context and you're near the bounds of the window or scrollable container, it's really nice when the page or container automatically scrolls. This lets you drag an item to any part of the list without needing to manually scroll and drag at the same time.
  • Drop criteria. There may be cases where you don't want some draggables to be allowed to drop in certain droppables (e.g. dragging a calendar event to a day that's already booked). Adding drop criteria would let you get really specific about when drop actions are valid.

Summary

Understanding the problem

Now that we've developed some language to describe the various drop behaviors, we can start to understand why the previous drop behavior was so painful. What made it frustrating was:

  • The drop indicator style
  • The tall height of draggable items
  • Our library didn't technically support nested drag and drop behavior

Using placeholder-style drop indicators and edge-based swap criteria is not inherently bad, but when you combine that approach with extremely tall draggable items, it's really difficult to drop the item where you want. Your cursor is always traveling a large distance to get to the desired location and once you're there, you might accidentally move it too far. Things get even worse when you're trying to thread the needle and move things into nested droppables.

For the use case of the previous product I worked on that had a form builder, I think the optimal behavior would be placeholder drop indicators (with edge-based swap criteria). This makes dropping more predictable, reduces the layout shift, and supports nested droppables.

Reflections

The bar is high. Writing drag and drop components from scratch is a fascinating interaction design problem. Drag and drop functionality is a ubiquitous part of many digital products, which makes all the more critical that the behavior feels natural and familiar (see Jakob's Law). The design problems range from interactive affordances – like hover states and cursor styles – to understanding user intent – like trying to use a placeholder drop indicator with nested droppables and edge-based swap criteria.

Decoding intent. Understanding user intent can be technically challenging. I learned that the desired design behavior may not always be feasible to implement, at least not in a straightforward way. This situation is typical from an engineering standpoint, but it resonates more deeply with me as a product designer, as I often find myself on the receiving end of the ā€œthis isn't feasibleā€ conversation.

Is it worth it? In the weeks I spent exploring this, I had a humbling realization: there's a chance that this user experience isn't the right one. Maybe the interaction details are confusing, or the UI layout isn't effective. Maybe it's not a product solution that has longevity. Product and business priorities change and the interface usually follows suit. It's a good reminder that delivering product value has to consider the effort and impact of the solution. Many times in this process I found myself asking: what's the ROI of getting the perfect design solution? Is it worth the hours I spent writing and rewriting and rewriting this drag and drop library and documenting my progress?

This exploration was difficult and frustrating. It really is a terrible idea (seriously go use something like dndkit). But I think everyone should take a gnarly problem they're interested in (even if there's already a library for it) and try and implement it themselves. You'll learn a lot and come to appreciate the alternatives.

Was it worth me spending my weekends staring at a computer screen?

For the sake of trying to improve a product I'm trying to improve, maybe.

For the sake of learning new things, absolutely.

Sam Bernhardt,Ā 2025