Drag & Drop

Building drag-and-drop functionality is a pretty common task in web development. With Qwik, you can easily implement drag-and-drop functionality by using the onDragStart$, onDragOver$, onDragLeave$, and onDrop$ APIs. You need to have in mind that Qwik processes events asynchronously. This means that some APIs such as event.preventDefault(), e.dataTransfer.getData() or e.dataTransfer.setData() do not work as expected.

To work around this limitations, Qwik provides a sync$() API which allows you to process events synchronously. For preventing the default behavior, you can use the preventdefault:dragover and preventdefault:drop attributes.

Basic example

import { component$, useSignal, sync$, $ } from '@builder.io/qwik';
 
export default component$(() => {
  const items1 = useSignal([
    { id: 1, content: '๐Ÿ“ฑ Phone' },
    { id: 2, content: '๐Ÿ’ป Laptop' },
    { id: 3, content: '๐ŸŽง Headphones' },
  ]);
 
  const items2 = useSignal([
    { id: 4, content: 'โŒš๏ธ Watch' },
    { id: 5, content: '๐Ÿ–ฑ Mouse' },
    { id: 6, content: 'โŒจ๏ธ Keyboard' },
  ]);
 
  return (
    <div class="flex min-h-screen justify-center gap-8 bg-gray-50 p-8">
      <div
        class="h-[25em] w-80 rounded-xl border-2 border-dashed border-gray-300 bg-white p-6 shadow-sm transition-all duration-300 hover:border-gray-400 [&[data-over]]:border-blue-300 [&[data-over]]:bg-blue-50"
        preventdefault:dragover
        preventdefault:drop
        onDragOver$={sync$((_: DragEvent, currentTarget: HTMLDivElement) => {
          currentTarget.setAttribute('data-over', 'true');
        })}
        onDragLeave$={sync$((_: DragEvent, currentTarget: HTMLDivElement) => {
          currentTarget.removeAttribute('data-over');
        })}
        onDrop$={[
          sync$((e: DragEvent, currentTarget: HTMLDivElement) => {
            const id = e.dataTransfer?.getData('text');
            currentTarget.dataset.droppedId = id;
            currentTarget.removeAttribute('data-over');
          }),
          $((_, currentTarget) => {
            const id = currentTarget.dataset.droppedId;
            if (id) {
              const itemId = parseInt(id);
              const item = [...items2.value].find((i) => i.id === itemId);
              if (item) {
                items2.value = items2.value.filter((i) => i.id !== itemId);
                items1.value = [...items1.value, item];
              }
            }
          }),
        ]}
      >
        <h3 class="mb-4 text-lg font-semibold text-gray-700">Container 1</h3>
        {items1.value.map((item) => (
          <div
            key={item.id}
            data-id={item.id}
            class="min-h-[62px] mb-3 cursor-move select-none rounded-lg border border-gray-200 bg-white p-4 transition-all duration-200 hover:-translate-y-1 hover:shadow-md active:scale-95"
            draggable
            onDragStart$={sync$(
              (e: DragEvent, currentTarget: HTMLDivElement) => {
                const itemId = currentTarget.getAttribute('data-id');
                if (e.dataTransfer && itemId) {
                  e.dataTransfer?.setData('text/plain', itemId);
                }
              }
            )}
          >
            <span class="text-lg text-gray-700">{item.content}</span>
          </div>
        ))}
      </div>
 
      <div
        class="h-[25em] w-80 rounded-xl border-2 border-dashed border-gray-300 bg-white p-6 shadow-sm transition-all duration-300 hover:border-gray-400 [&[data-over]]:border-blue-300 [&[data-over]]:bg-blue-50"
        preventdefault:dragover
        preventdefault:drop
        onDragOver$={sync$((_: DragEvent, currentTarget: HTMLDivElement) => {
          currentTarget.setAttribute('data-over', 'true');
        })}
        onDragLeave$={sync$((_: DragEvent, currentTarget: HTMLDivElement) => {
          currentTarget.removeAttribute('data-over');
        })}
        onDrop$={[
          sync$((e: DragEvent, currentTarget: HTMLDivElement) => {
            const id = e.dataTransfer?.getData('text');
            currentTarget.dataset.droppedId = id;
            currentTarget.removeAttribute('data-over');
          }),
          $((_, currentTarget) => {
            const id = currentTarget.dataset.droppedId;
            if (id) {
              const itemId = parseInt(id);
              const item = [...items1.value].find((i) => i.id === itemId);
              if (item) {
                items1.value = items1.value.filter((i) => i.id !== itemId);
                items2.value = [...items2.value, item];
              }
            }
          }),
        ]}
      >
        <h3 class="mb-4 text-lg font-semibold text-gray-700">Container 2</h3>
        {items2.value.map((item) => (
          <div
            key={item.id}
            data-id={item.id}
            class="min-h-[62px] mb-3 cursor-move select-none rounded-lg border border-gray-200 bg-white p-4 transition-all duration-200 hover:-translate-y-1 hover:shadow-md active:scale-95"
            draggable
            onDragStart$={sync$(
              (e: DragEvent, currentTarget: HTMLDivElement) => {
                const itemId = currentTarget.getAttribute('data-id');
                if (e.dataTransfer && itemId) {
                  e.dataTransfer?.setData('text/plain', itemId);
                }
              }
            )}
          >
            <span class="text-lg text-gray-700">{item.content}</span>
          </div>
        ))}
      </div>
    </div>
  );
});

Advanced example with sorting

import { component$, sync$, useSignal, $ } from '@builder.io/qwik';
 
type Item = {
  id: number;
  content: string;
};
 
export default component$(() => {
  const items1 = useSignal<Item[]>([
    { id: 1, content: '๐Ÿ“ฑ Phone' },
    { id: 2, content: '๐Ÿ’ป Laptop' },
    { id: 3, content: '๐ŸŽง Headphones' },
  ]);
 
  const items2 = useSignal<Item[]>([
    { id: 4, content: 'โŒš๏ธ Watch' },
    { id: 5, content: '๐Ÿ–ฑ Mouse' },
    { id: 6, content: 'โŒจ๏ธ Keyboard' },
  ]);
 
  return (
    <div class="flex min-h-screen justify-center gap-8 bg-gray-50 p-8">
      <div
        data-dropzone
        class="h-[25em] w-80 rounded-xl border-2 border-dashed border-gray-300 bg-white p-6 shadow-sm transition-all duration-300 hover:border-gray-400 [&[data-over]]:border-blue-300 [&[data-over]]:bg-blue-50"
        preventdefault:dragover
        preventdefault:drop
        onDragOver$={sync$((_: DragEvent, currentTarget: HTMLDivElement) => {
          currentTarget.setAttribute('data-over', 'true');
        })}
        onDragLeave$={sync$((_: DragEvent, currentTarget: HTMLDivElement) => {
          currentTarget.removeAttribute('data-over');
        })}
        onDrop$={[
          sync$((e: DragEvent, currentTarget: HTMLDivElement) => {
            const id = e.dataTransfer?.getData('text/plain');
            currentTarget.dataset.droppedId = id;
            currentTarget.removeAttribute('data-over');
          }),
          $((e, currentTarget) => {
            const draggedElementId = currentTarget.dataset.droppedId;
            const isDropZone = currentTarget.hasAttribute('data-dropzone');
 
            if (draggedElementId) {
              const itemId = parseInt(draggedElementId);
              const item = items2.value.find((i) => i.id === itemId);
 
              if (item && isDropZone) {
                items2.value = items2.value.filter((i) => i.id !== itemId);
                items1.value = [...items1.value, item];
              } else {
                const newItems = [...items1.value];
                const targetId = parseInt(
                  (e.target as HTMLDivElement).dataset.id || '0'
                );
                if (targetId === 0) return;
 
                const targetIndex = items1.value.findIndex(
                  (i) => i.id === targetId
                );
                const draggedIndex = items1.value.findIndex(
                  (i) => i.id === itemId
                );
 
                if (draggedIndex !== -1) {
                  // Sorting in the same container
                  swapElements(newItems, draggedIndex, targetIndex);
                  items1.value = newItems;
                } else {
                  // Sorting between containers
                  if (!item) return;
                  items2.value = items2.value.filter((i) => i.id !== itemId);
                  insertElement(newItems, targetIndex, item);
                  items1.value = newItems;
                }
              }
            }
          }),
        ]}
      >
        <h3 class="mb-4 text-lg font-semibold text-gray-700">Container 1</h3>
        {items1.value.map((item) => (
          <div
            key={item.id}
            data-id={item.id}
            class="min-h-[62px] mb-3 cursor-move select-none rounded-lg border border-gray-200 bg-white p-4 transition-all duration-200 hover:-translate-y-1 hover:shadow-md active:scale-95"
            draggable
            onDragStart$={sync$(
              (e: DragEvent, currentTarget: HTMLDivElement) => {
                const itemId = currentTarget.getAttribute('data-id');
                if (e.dataTransfer && itemId) {
                  e.dataTransfer.setData('text/plain', itemId);
                }
              }
            )}
          >
            <span class="text-lg text-gray-700">{item.content}</span>
          </div>
        ))}
      </div>
 
      <div
        class="h-[25em] w-80 rounded-xl border-2 border-dashed border-gray-300 bg-white p-6 shadow-sm transition-all duration-300 hover:border-gray-400 [&[data-over]]:border-blue-300 [&[data-over]]:bg-blue-50"
        data-dropzone
        preventdefault:dragover
        preventdefault:drop
        onDragOver$={(_: DragEvent, currentTarget: HTMLDivElement) => {
          currentTarget.setAttribute('data-over', 'true');
        }}
        onDragLeave$={[
          sync$((_: DragEvent, currentTarget: HTMLDivElement) => {
            currentTarget.removeAttribute('data-over');
          }),
        ]}
        onDrop$={[
          sync$((e: DragEvent, currentTarget: HTMLDivElement) => {
            const id = e.dataTransfer?.getData('text/plain');
            currentTarget.dataset.droppedId = id;
            currentTarget.removeAttribute('data-over');
          }),
          $((e, currentTarget) => {
            const draggedElementId = currentTarget.dataset.droppedId;
            const isDropZone = currentTarget.hasAttribute('data-dropzone');
 
            if (draggedElementId) {
              const itemId = parseInt(draggedElementId);
              const item = items1.value.find((i) => i.id === itemId);
 
              if (isDropZone && item) {
                items1.value = items1.value.filter((i) => i.id !== itemId);
                items2.value = [...items2.value, item];
              } else {
                const targetId = parseInt(
                  (e.target as HTMLDivElement).dataset.id || '0'
                );
                if (targetId === 0) return;
                const newItems = [...items2.value];
                const draggedIndex = items2.value.findIndex(
                  (i) => i.id === itemId
                );
                const targetIndex = items2.value.findIndex(
                  (i) => i.id === targetId
                );
                if (draggedIndex !== -1) {
                  // Sorting in the same container
                  swapElements(newItems, targetIndex, draggedIndex);
                  items2.value = newItems;
                } else {
                  // Sorting between containers
                  if (!item) return;
                  items1.value = items1.value.filter((i) => i.id !== itemId);
                  insertElement(newItems, targetIndex, item);
                  items2.value = newItems;
                }
              }
            }
          }),
        ]}
      >
        <h3 class="mb-4 text-lg font-semibold text-gray-700">Container 2</h3>
        {items2.value.map((item) => (
          <div
            key={item.id}
            data-id={item.id}
            class="min-h-[62px] mb-3 cursor-move select-none rounded-lg border border-gray-200 bg-white p-4 transition-all duration-200 hover:-translate-y-1 hover:shadow-md active:scale-95"
            draggable
            onDragStart$={sync$(
              (e: DragEvent, currentTarget: HTMLDivElement) => {
                const itemId = currentTarget.getAttribute('data-id');
                if (e.dataTransfer && itemId) {
                  e.dataTransfer.setData('text/plain', itemId);
                }
              }
            )}
          >
            <span class="text-lg text-gray-700">{item.content}</span>
          </div>
        ))}
      </div>
    </div>
  );
});
 
function swapElements(arr: Item[], index1: number, index2: number) {
  arr[index1] = arr.splice(index2, 1, arr[index1])[0];
 
  return arr;
}
 
function insertElement(arr: Item[], index: number, item: Item) {
  arr.splice(index, 0, item);
  return arr;
}
 

You can find more information about drag-and-drop in the MDN documentation.

Contributors

Thanks to all the contributors who have helped make this documentation better!

  • byte-barista