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-xs 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-xs 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-xs 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-xs 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.