Implementing a drag and drop sortable list in vanilla javascript using pointer events

Drag and drop sortable lists are a common design pattern nowadays. You have a list of items, and if you want to change the order of them, you just drag them with a finger, or the mouse. As you do so, the item being dragged floats, and a gap is inserted into the list indicating where the floating item will go when the user drops it.

This article explains how to implement this functionality using pointer events, a JavaScript API which unifies mouse, touch and other input devices into a single event type. Consequently, the code is pretty easy to understand, and it works well on all devices, including mobile.

Rather than explaining the code line by line, which would take quite a long time, this article describes the general structure of the code, and thought process I went through in implementing it. I've included a CodePen embed with my full code, so you can play with it.

The code

See the Pen

drag and drop list by Robert Hickman (@robehickman)

on CodePen.

Breaking down the problem

Thinking about the problem, it can be broken down as follows:

  • We need a way of detecting what the user dragged, where they dragged it, and when they released it.
  • We need a way of detecting what is under the item the user is dragging, so that we can indicate to the user where it will go when dropped, and insert it into the correct position when they do.
  • We need a way of moving the item being dragged, and to insert a gap into the list to indicate where it will drop.
  • We need to scroll the screen when the item being dragged reaches the top or bottom of the viewport.

The code is logically organised into sections which implement these aspects, and the sections below explain how they work:

Handing drag and drop

Instead of explaining the basics process of implementing drag and drop using pointer events, I'm going to differ you to the article Perfecting drag and drop in pure vanilla javascript, and focus only on the issues pertaining to the specific problem here.

This aspect actually isn't that difficult, we just need to bind to 3 events, pointerdown, pointermove, pointerup:

  • Pointerdown, detect what the user interacted with, and initialise the item for moving, draw the gap in the starting position.
  • Pointermove, move the item and reposition the gap as needed.
  • Pointerup, insert into the list at final position and clean up.

So that animations happen in sync with the browser's render loop, the actual rendering of movements is done within a requestAnimationFrame() callback, and the pointer event movement hander just updates a variable with the current pointer location.

We also need to register a pointerleave event, so that the dragging is cancelled when the cursor leaves the browser viewport. Without it, if the user un-clicks the mouse outside the viewport, the code gets stuck in a state where there is no way to cancel dragging.

Moving the floating item, and adding a gap to the list

Working out how to move the item and add the gap was by far the most difficult aspect of implementing this. I initially had 3 ideas how to approach this, all of which involving directly manipulating the order of items in the DOM:

  • Redraw the whole list every frame, inserting the gap in the desired location.
  • Insert a gap element at a given index in the list, removing and reinserting it as the floating element moves.
  • Swap adjacent items as the user drags the floating item.

In practice none of these worked well, as they were either too slow, or resulted in visual artefacts as the browser was not performing removal and insertion of DOM nodes atomically.

What did work was to mostly leave the DOM alone, and create all visual movement using the transform: translate CSS property. In summary, this is what I ended up doing:

  • Ensure the parent element has position: relative so we can absolute position elements in relation to it, not the browser viewport.
  • Move the item being dragged to the end of the list so we don't have to think about it when drawing the gap.
  • Set "position: absolute" and "top: 0" on the floating item, so that translate happens relative to the top of the container.
  • Position the floating element under the cursor using transform: translate.
  • Create the gap in the list also using transform: translate, by moving all of the items after where the gap should be down by the size of the floating element.

A bonus of this approach, moving the elements instead of restructuring the DOM, is that it's really easy to add visual transitions using CSS.

Detecting which list item the floating item is hovering over

Detecting which item in the list the floating item is overlaying was done in the following way:

  • During the pointerdown event, we store the screen positions of all of the items in the list using getBoundingClientRect(), offset by the current scroll position.
  • Every time the user moves the cursor, we loop through the whole list of coordinates, and check if(cursor > box top && cursor < box_bottom), to find which item is under the cursor.
  • We also clamp the position to the first or last item if the cursor position is above or below the sortable list.
  • Using this, we work out the index of the item, which we use to draw the gap, and also insert it

It may also be possible to do this using the intersection observer api, but personally I didn't like it when I looked at it, as there seemed to be no way to directly query, 'is this element over this other element right now', rather it's based on callbacks, and that wasn't what I wanted.

Handling scrolling of the list

Finally, we want the list to scroll, when the item being dragged is close to the top or bottom of the scrollablle area. This is pretty easy to do:

  • When the item is some distance away from the top or bottom of the screen, we scroll the screen in that direction, we call that the scroll trigger area.
  • Having a fixed size scroll trigger at all times does not work well, as if the user starts dragging an item close to the edge of the screen, the screen starts scrolling unexpectedly.
  • The size of the scroll trigger is reduced to the position the user initially touched, so that moving towards the edge of the screen starts the scroll in that direction.
  • Moving towards the centre of the screen expands the size of the scroll trigger, so that when the user moves their pointer towards the edge again, it starts scrolling the screen earlier.

Closing notes

Personally, I learned quite a lot about how to approach things in browsers while writing this. For example, I expected that approaches of re-ordering the DOM nodes in real-time would be fast, as I assumed the browser would be storing these internally as independent heap-allocated structures (think C structs) pointed to by references in the parent element.

I expected that re-ordering items in the DOM would amount to nothing more than rearranging pointers in memory. If that was the case we could easily re-draw the whole list at 60+ FPS on a modern computer, even if it contained thousands of elements. The browser already knows the sizes of these elements, and that we have not resized them, so there shouldn't be much work to do.

In practice, this approach was very slow and visually jittery, so I guess my mental model of browser implementation must be wrong. I have learned that the best way of moving items in real time seems to be translating them using CSS transforms, as I noted.

Finally, there are a few obvious ways that this code could be optimised:

  • Put the location look-up data into a special hierarchy, instead of looping through the whole array linearly every frame.
  • Don't transform elements which are outside the viewport. The previous point about a special hierarchy could allow us to quickly query what set of elements are currently visible.

However in practice, I find the naive approach fast enough for my needs.