Using native HTML5 drag and drop functionality

When making my React todo app, I wanted to make each item on the list draggable. A quick Google search question to ChatGPT and I had a few solutions. There were various drag and drop libraries I could use but also something I wasn’t expecting - native HTML5 drag and drop functionality😱

Before jumping into the third party libraries, I thought I ought to educate myself on the native solution first. This article documents what I learned.

Basic implementation of HTML5 drag and drop functionality

This is very basic implementation of HTML5 drag and drop functionality and looks to be the most minimal way of getting it to work. It uses the dragstart, dragover and drop events to allow you to drag and drop items in a list. Give it a try. You can see it’s not the best UX, but it works.

  • Item One
  • Item Two
  • Item Three
const list = document.querySelector('[data-draggable-list="1"]');
  let draggingEl = null;

  list.querySelectorAll('[data-draggable]').forEach((el) => {
    el.addEventListener('dragstart', () => (draggingEl = el));
    el.addEventListener('dragover', (e) => e.preventDefault());
    el.addEventListener('drop', () => {
      if (draggingEl !== el) {
        const items = [...list.children];
        const from = items.indexOf(draggingEl);
        const to = items.indexOf(el);
        list.insertBefore(draggingEl, from < to ? el.nextSibling : el);
      }
    });
  });

While it technically works, the experience feels lacking compared to modern drag-and-drop interactions. So I started to look at how I could enhance the UX.

Enhancing HTML5 drag and drop UX

We can enhance the user experience of native HTML5 drag and drop functionality by adding a few simple features:

  1. Adding the standard dots icon to indicate the items are draggable.
  2. Lowering the opacity of the item currently being dragged.
  3. Realtime feedback of an items position in the list to indicate where an item will be dropped.

I’ve taken the basic example and implemented all three of these enhancements. See the working example below:

  • Item One
  • Item Two
  • Item Three
const list = document.querySelector('[data-draggable-list="2"]');
  let draggingEl = null; // currently dragging element

  list.querySelectorAll('[data-draggable]').forEach((el) => {
    el.addEventListener('dragstart', () => {
      draggingEl = el;
      el.classList.add('opacity-50');
    });

    el.addEventListener('dragend', () => {
      el.classList.remove('opacity-50');
      draggingEl = null;
    });

    el.addEventListener('dragover', (e) => {
      e.preventDefault();

      if (el === draggingEl) return;

      // Convert node list to array
      // to find the index of the dragging element and target
      const items = Array.from(list.querySelectorAll('[data-draggable]'));
      const draggingIndex = items.indexOf(draggingEl);
      const targetIndex = items.indexOf(el);

      // Insert draggingEl after target if dragging downwards, before target if upwards
      const insertBeforeEl = draggingIndex < targetIndex ? el.nextSibling : el;
      list.insertBefore(draggingEl, insertBeforeEl);
    });
  });

The change in opacity is handled in the dragstart and dragend events. When the drag starts, we add a Tailwind class to the item and remove it when the drag ends.

The main difference in the code compared with the basic implementation is the addition of the dragover event. It replaces the drop event listener in the basic example. As the name suggests, the dragover event is triggered when an element is dragged over it and is how we handle the real-time feedback of the items position in the list.

When an item is dragged over another item, we check if the item being dragged is the same as the item being hovered over. If it is, we return early. This prevents the item from being dropped on itself. Then we convert the node list of items into an array so we can find both the index of the item being dragged and the item being hovered over. We then check if the item being dragged is above or below the item being hovered over. If it’s above, we insert it after the hovered item. If it’s below, we insert it before the hovered item.

Adding reorder transitions

The above example works great, but there is one more thing we could do to add some polish to the UX. That is a short animated transition when items switch places. This is a nice touch that is used on other apps and makes the drag and drop experience feel more fluid and responsive:

  • Item One
  • Item Two
  • Item Three
const list = document.querySelector('[data-draggable-list="3"]');
  let draggingEl = null; 
  let lastTarget = null; // last insert target

  list.querySelectorAll('[data-draggable]').forEach((el) => {
    el.addEventListener('dragstart', () => {
      draggingEl = el;
      el.classList.add('opacity-50');
    });

    el.addEventListener('dragend', () => {
      el.classList.remove('opacity-50');
      draggingEl = null;
      lastTarget = null; // reset lastTarget
    });

    el.addEventListener('dragover', (e) => {
      e.preventDefault();

      if (el === draggingEl) return;
      if (el === lastTarget) return;

      const items = Array.from(list.querySelectorAll('[data-draggable]'));
      const draggingIndex = items.indexOf(draggingEl);
      const targetIndex = items.indexOf(el);

      const insertBeforeEl = draggingIndex < targetIndex ? el.nextSibling : el;

      lastTarget = el;

      animateReorder(list, draggingEl, insertBeforeEl).then(() => {
        // Animation finished — reset lastTarget so reordering can continue
        lastTarget = null;
      });
    });
  });

  function animateReorder(list, dragging, target) {
    return new Promise((resolve) => {
      // Get all draggable items except the one being dragged
      const items = [...list.querySelectorAll('[data-draggable]')].filter((el) => el !== dragging);

      // Record their current positions
      const positions = new Map(items.map((el) => [el, el.getBoundingClientRect()]));

      // Move the dragged element to its new place in the DOM
      if (target) {
        list.insertBefore(dragging, target);
      } else {
        list.appendChild(dragging);
      }

      // Wait for the browser to paint
      requestAnimationFrame(() => {
        let remaining = 0;

        items.forEach((el) => {
          const oldY = positions.get(el);
          if (!oldY) return;

          const newY = el.getBoundingClientRect();
          const yShift = oldY.top - newY.top;

          if (yShift !== 0) {
            remaining++;

            // Jump element back to old position instantly
            el.style.transition = 'none';
            el.style.transform = `translateY(${yShift}px)`;

            // Force reflow for immediate effect
            el.getBoundingClientRect();

            // Animate element back to new position smoothly
            el.style.transition = 'transform 150ms ease';
            el.style.transform = '';

            // When animation ends, check if all are done
            el.addEventListener('transitionend', function handler(e) {
              if (e.propertyName === 'transform') {
                el.removeEventListener('transitionend', handler);
                remaining--;
                if (remaining === 0) resolve();
              }
            });
          }
        });

        // If no items moved, resolve immediately
        if (remaining === 0) resolve();
      });
    });
  }

The main difference in this code is the addition of the animateReorder() function. This function takes the list, the item being dragged and the target item as arguments. It uses the getBoundingClientRect() method to get the position of all items in the list and calculates the difference between their old and new positions. It then animates the items back to their new positions using CSS transitions.

We also need to add a lastTarget variable to keep track of the last item that was hovered over. This is used to prevent the animation from firing multiple times when the item is dragged over the same item, for example when the item switches places with the item being dragged. So the animateReorder() function uses a Promise to wait for the animation to finish before resolving. This allows us to reset the lastTarget variable after the animation is complete.

Truth be told, I worked quite closely with ChatGPT to generate the animateReorder function. I’ve used FramerMotion for React apps, but not done this using vanilla JS. So I learnt a lot building it. It just adds a little polish to the HTML5 drag and drop functionality.