JavaScript Essentials

Drag & Drop API

45 min Lesson 55 of 60

Introduction to the HTML5 Drag & Drop API

The HTML5 Drag and Drop API allows users to grab an element on a web page, drag it to a different location, and drop it there. This interaction pattern is used in file managers, kanban boards, image uploaders, sortable lists, and many other user interfaces. Before HTML5, implementing drag and drop required complex mouse event tracking and manual coordinate calculations. The native Drag and Drop API provides a standardized, event-driven approach that works across all modern browsers without any external libraries.

The Drag and Drop API is built around a series of events that fire during different phases of the drag operation. Understanding these events and the DataTransfer object that carries data between them is the key to mastering this API. In this lesson, you will learn every aspect of the API: from making elements draggable, to handling all seven drag events, to building complete real-world features like sortable lists, kanban boards, and file drop zones.

The API consists of three core concepts. First, the draggable attribute makes an HTML element draggable. Second, the drag events fire at various stages of the drag-and-drop lifecycle. Third, the DataTransfer object carries data and configuration between the drag source and the drop target. Together, these pieces enable rich interactive drag-and-drop experiences entirely in the browser.

Making Elements Draggable

By default, only images, selected text, and links are draggable in HTML. To make any other element draggable, you must add the draggable="true" attribute to it. This tells the browser that the element can be picked up and dragged by the user. Without this attribute, dragging attempts on regular elements like divs and paragraphs will have no effect.

Example: Making Elements Draggable

<!-- This div is draggable -->
<div draggable="true" id="drag-item">
    Drag me around the page!
</div>

<!-- Images are draggable by default -->
<img src="photo.jpg" alt="A photo" />

<!-- Links are draggable by default -->
<a href="https://example.com">Drag this link</a>

<!-- To prevent default dragging on images -->
<img src="logo.png" alt="Logo" draggable="false" />
Note: The draggable attribute is not a boolean attribute like hidden or disabled. You must explicitly set it to "true" or "false". Writing just draggable without a value may not work consistently across all browsers.

When an element becomes draggable, the browser provides a visual ghost image of the element that follows the cursor during the drag. The original element remains in place and typically becomes semi-transparent. You can customize this ghost image using the DataTransfer API, which we will cover later in this lesson.

The Seven Drag Events

The Drag and Drop API defines seven distinct events that fire during a drag operation. These events are split between the drag source (the element being dragged) and the drop target (the element where the dragged item can be dropped). Understanding when each event fires and on which element it fires is critical.

Events on the Drag Source

Three events fire on the element that is being dragged:

  • dragstart -- Fires when the user begins dragging the element. This is where you set the data to transfer and configure the drag operation.
  • drag -- Fires continuously while the element is being dragged. This event fires every few hundred milliseconds and is useful for updating UI during the drag.
  • dragend -- Fires when the drag operation ends, whether the element was dropped on a valid target or not. This is where you clean up any visual changes made during the drag.

Events on the Drop Target

Four events fire on elements that can receive dropped items:

  • dragenter -- Fires when a dragged element enters the bounds of a potential drop target. Use this to add visual feedback showing the user they can drop here.
  • dragover -- Fires continuously while a dragged element is over a drop target. You must call event.preventDefault() in this handler to allow dropping -- by default, elements do not accept drops.
  • dragleave -- Fires when a dragged element leaves the bounds of a drop target. Use this to remove the visual feedback added in dragenter.
  • drop -- Fires when the user releases the dragged element over a valid drop target. This is where you handle the actual drop logic -- reading transferred data and updating the DOM.

Example: All Seven Drag Events in Action

<div id="drag-source" draggable="true">Drag Me</div>
<div id="drop-zone">Drop Here</div>

<script>
const source = document.getElementById('drag-source');
const zone = document.getElementById('drop-zone');

// Events on the drag source
source.addEventListener('dragstart', (e) => {
    console.log('dragstart: Drag has begun');
    e.dataTransfer.setData('text/plain', source.id);
    source.style.opacity = '0.5';
});

source.addEventListener('drag', (e) => {
    // Fires continuously during drag
    // Use sparingly to avoid performance issues
});

source.addEventListener('dragend', (e) => {
    console.log('dragend: Drag has ended');
    source.style.opacity = '1';
});

// Events on the drop target
zone.addEventListener('dragenter', (e) => {
    e.preventDefault();
    console.log('dragenter: Element entered drop zone');
    zone.classList.add('highlight');
});

zone.addEventListener('dragover', (e) => {
    e.preventDefault(); // Required to allow drop
    console.log('dragover: Element is over drop zone');
});

zone.addEventListener('dragleave', (e) => {
    console.log('dragleave: Element left drop zone');
    zone.classList.remove('highlight');
});

zone.addEventListener('drop', (e) => {
    e.preventDefault();
    console.log('drop: Element was dropped');
    const draggedId = e.dataTransfer.getData('text/plain');
    const draggedEl = document.getElementById(draggedId);
    zone.appendChild(draggedEl);
    zone.classList.remove('highlight');
});
</script>
Critical: You must call event.preventDefault() in the dragover event handler. Without this call, the browser will not allow the drop to occur and the drop event will never fire. This is the single most common mistake when implementing drag and drop.

The DataTransfer Object

The DataTransfer object is the central mechanism for passing data between the drag source and the drop target. It is available on the event object as event.dataTransfer during drag events. The DataTransfer object provides methods to set and retrieve data, specify the allowed drag effects, customize the drag image, and access dragged files.

setData() and getData()

The setData() method stores data in the DataTransfer object during the dragstart event. The getData() method retrieves that data during the drop event. Both methods take a format string (MIME type) as their first argument. You can store multiple pieces of data with different format keys.

Example: Using setData and getData

// In the dragstart handler -- set data
element.addEventListener('dragstart', (e) => {
    // Store plain text
    e.dataTransfer.setData('text/plain', 'Hello World');

    // Store HTML content
    e.dataTransfer.setData('text/html', '<strong>Hello</strong>');

    // Store a URL
    e.dataTransfer.setData('text/uri-list', 'https://example.com');

    // Store custom JSON data
    e.dataTransfer.setData('application/json', JSON.stringify({
        id: 42,
        type: 'task',
        title: 'Complete lesson'
    }));
});

// In the drop handler -- get data
dropZone.addEventListener('drop', (e) => {
    e.preventDefault();

    // Retrieve plain text
    const text = e.dataTransfer.getData('text/plain');

    // Retrieve custom JSON
    const jsonStr = e.dataTransfer.getData('application/json');
    const data = JSON.parse(jsonStr);
    console.log(data.title); // "Complete lesson"
});

The types Property

The types property returns a DOMStringList of all the data formats that were set during dragstart. This is useful in the dragover or drop handlers to check what kind of data is being dragged before deciding whether to accept the drop.

Example: Checking DataTransfer Types

dropZone.addEventListener('dragover', (e) => {
    // Only allow drop if the dragged data contains our custom type
    if (e.dataTransfer.types.includes('application/json')) {
        e.preventDefault(); // Allow the drop
    }
    // If the data does not include our type, the drop is not allowed
});

dropZone.addEventListener('drop', (e) => {
    e.preventDefault();

    // List all available types
    console.log('Available types:', [...e.dataTransfer.types]);

    // Check for specific type before reading
    if (e.dataTransfer.types.includes('text/html')) {
        const html = e.dataTransfer.getData('text/html');
        console.log('Received HTML:', html);
    }
});

The files Property

When the user drags files from their desktop or file manager into the browser, the files property of the DataTransfer object contains a FileList of the dragged files. This is the foundation for building file upload drop zones.

Example: Accessing Dragged Files

dropZone.addEventListener('drop', (e) => {
    e.preventDefault();

    const files = e.dataTransfer.files;
    console.log('Number of files:', files.length);

    for (const file of files) {
        console.log('Name:', file.name);
        console.log('Type:', file.type);
        console.log('Size:', file.size, 'bytes');
        console.log('Last modified:', new Date(file.lastModified));
    }
});

effectAllowed and dropEffect

The DataTransfer object provides two properties that control the visual feedback and the type of drag operation: effectAllowed and dropEffect. The effectAllowed property is set in the dragstart handler and specifies which types of operations are permitted. The dropEffect property is set in the dragover handler and specifies which operation will actually occur if the item is dropped.

Example: Setting effectAllowed and dropEffect

// effectAllowed values:
// "none"       -- no operation permitted
// "copy"       -- copy only
// "move"       -- move only
// "link"       -- link only
// "copyMove"   -- copy or move
// "copyLink"   -- copy or link
// "linkMove"   -- link or move
// "all"        -- copy, move, or link (default)

source.addEventListener('dragstart', (e) => {
    e.dataTransfer.setData('text/plain', source.id);
    e.dataTransfer.effectAllowed = 'move'; // Only moving allowed
});

// dropEffect values: "none", "copy", "move", "link"
dropZone.addEventListener('dragover', (e) => {
    e.preventDefault();
    e.dataTransfer.dropEffect = 'move'; // Cursor shows move icon
});

// Conditional drop effect based on modifier keys
dropZone.addEventListener('dragover', (e) => {
    e.preventDefault();
    if (e.ctrlKey) {
        e.dataTransfer.dropEffect = 'copy'; // Ctrl + drag = copy
    } else {
        e.dataTransfer.dropEffect = 'move'; // Default = move
    }
});
Tip: When effectAllowed is set to "move", the browser will show a move cursor during the drag. When set to "copy", it shows a copy cursor (usually a plus sign). This visual feedback helps users understand what will happen when they drop the item.

Custom Drag Images

By default, the browser creates a semi-transparent snapshot of the dragged element as the drag image. You can customize this by using the setDragImage() method on the DataTransfer object. This method takes three arguments: an image element (or any element), and x and y offsets that specify where the cursor will be positioned relative to the image.

Example: Custom Drag Images

// Using a custom image as the drag image
source.addEventListener('dragstart', (e) => {
    e.dataTransfer.setData('text/plain', source.id);

    // Create an image element for the drag image
    const dragImage = new Image();
    dragImage.src = '/icons/drag-icon.png';

    // Set the drag image with cursor at center (25, 25)
    e.dataTransfer.setDragImage(dragImage, 25, 25);
});

// Using a hidden DOM element as the drag image
source.addEventListener('dragstart', (e) => {
    e.dataTransfer.setData('text/plain', source.id);

    // Create a custom element for the drag preview
    const preview = document.createElement('div');
    preview.textContent = 'Moving item...';
    preview.style.cssText = `
        position: absolute;
        top: -1000px;
        padding: 8px 16px;
        background: #3498db;
        color: white;
        border-radius: 4px;
        font-size: 14px;
    `;
    document.body.appendChild(preview);

    // Use the custom element as drag image
    e.dataTransfer.setDragImage(preview, 0, 0);

    // Clean up the element after a short delay
    requestAnimationFrame(() => {
        document.body.removeChild(preview);
    });
});

Building Drop Zones

A drop zone is any element that accepts dragged items. To create a drop zone, you must handle at least the dragover and drop events, calling preventDefault() on both. Adding visual feedback through dragenter and dragleave events greatly improves the user experience by making it clear where items can be dropped.

Example: Complete Drop Zone with Visual Feedback

<style>
.drop-zone {
    width: 300px;
    height: 200px;
    border: 2px dashed #ccc;
    border-radius: 8px;
    display: flex;
    align-items: center;
    justify-content: center;
    transition: all 0.3s ease;
    color: #999;
    font-size: 16px;
}

.drop-zone.active {
    border-color: #3498db;
    background-color: rgba(52, 152, 219, 0.05);
    color: #3498db;
}

.drop-zone.hover {
    border-color: #2ecc71;
    background-color: rgba(46, 204, 113, 0.1);
    transform: scale(1.02);
}
</style>

<div class="drop-zone" id="dropZone">
    Drop items here
</div>

<script>
const dropZone = document.getElementById('dropZone');
let dragCounter = 0; // Track nested dragenter/dragleave

dropZone.addEventListener('dragenter', (e) => {
    e.preventDefault();
    dragCounter++;
    dropZone.classList.add('hover');
});

dropZone.addEventListener('dragover', (e) => {
    e.preventDefault();
    e.dataTransfer.dropEffect = 'move';
});

dropZone.addEventListener('dragleave', (e) => {
    dragCounter--;
    if (dragCounter === 0) {
        dropZone.classList.remove('hover');
    }
});

dropZone.addEventListener('drop', (e) => {
    e.preventDefault();
    dragCounter = 0;
    dropZone.classList.remove('hover');

    const data = e.dataTransfer.getData('text/plain');
    // Handle the dropped data
    console.log('Dropped:', data);
});
</script>
Note: The dragCounter variable solves a common problem: when dragging over a drop zone that contains child elements, dragenter and dragleave events fire for each child element. Without tracking the counter, the visual feedback flickers as you move between child elements. Incrementing on dragenter and decrementing on dragleave ensures the highlight only disappears when the cursor truly leaves the drop zone.

Reordering Lists with Drag and Drop

One of the most common use cases for drag and drop is reordering items in a list. The user drags an item from one position and drops it at another position within the same list. This requires tracking which element is being dragged, determining the drop position relative to other elements, and rearranging the DOM accordingly.

Example: Sortable List

<ul id="sortable-list">
    <li draggable="true">Item 1</li>
    <li draggable="true">Item 2</li>
    <li draggable="true">Item 3</li>
    <li draggable="true">Item 4</li>
    <li draggable="true">Item 5</li>
</ul>

<script>
const list = document.getElementById('sortable-list');
let draggedItem = null;

list.addEventListener('dragstart', (e) => {
    draggedItem = e.target;
    e.target.style.opacity = '0.4';
    e.dataTransfer.effectAllowed = 'move';
    e.dataTransfer.setData('text/html', e.target.innerHTML);
});

list.addEventListener('dragover', (e) => {
    e.preventDefault();
    e.dataTransfer.dropEffect = 'move';

    const target = e.target.closest('li');
    if (!target || target === draggedItem) return;

    // Determine if we should insert before or after
    const rect = target.getBoundingClientRect();
    const midpoint = rect.top + rect.height / 2;

    if (e.clientY < midpoint) {
        // Mouse is in the top half -- insert before
        list.insertBefore(draggedItem, target);
    } else {
        // Mouse is in the bottom half -- insert after
        list.insertBefore(draggedItem, target.nextSibling);
    }
});

list.addEventListener('dragend', (e) => {
    e.target.style.opacity = '1';
    draggedItem = null;
});
</script>

The key technique here is calculating the midpoint of each list item during dragover. By comparing the mouse cursor position (e.clientY) to this midpoint, we determine whether the dragged item should be placed before or after the target item. This creates a smooth, intuitive reordering experience where the insertion point follows the cursor naturally.

Dragging Between Multiple Containers

Many applications require dragging items between different containers -- for example, moving tasks between columns in a project management tool, or organizing files into folders. This pattern extends the basic drop zone approach by having multiple containers that each accept drops and identifying which container the item came from and where it is going.

Example: Dragging Between Containers

<div class="container" id="container-a">
    <h3>Container A</h3>
    <div class="item" draggable="true" data-id="1">Item 1</div>
    <div class="item" draggable="true" data-id="2">Item 2</div>
</div>

<div class="container" id="container-b">
    <h3>Container B</h3>
    <div class="item" draggable="true" data-id="3">Item 3</div>
</div>

<script>
const containers = document.querySelectorAll('.container');
let draggedItem = null;

// Attach drag events to all items
document.querySelectorAll('.item').forEach(item => {
    item.addEventListener('dragstart', (e) => {
        draggedItem = e.target;
        e.dataTransfer.setData('text/plain', e.target.dataset.id);
        e.dataTransfer.effectAllowed = 'move';
        setTimeout(() => {
            e.target.classList.add('dragging');
        }, 0);
    });

    item.addEventListener('dragend', (e) => {
        e.target.classList.remove('dragging');
        draggedItem = null;
    });
});

// Attach drop zone events to all containers
containers.forEach(container => {
    container.addEventListener('dragover', (e) => {
        e.preventDefault();
        e.dataTransfer.dropEffect = 'move';

        // Find the element to insert before
        const afterElement = getDragAfterElement(container, e.clientY);
        if (afterElement) {
            container.insertBefore(draggedItem, afterElement);
        } else {
            container.appendChild(draggedItem);
        }
    });

    container.addEventListener('dragenter', (e) => {
        e.preventDefault();
        container.classList.add('drag-over');
    });

    container.addEventListener('dragleave', (e) => {
        if (!container.contains(e.relatedTarget)) {
            container.classList.remove('drag-over');
        }
    });

    container.addEventListener('drop', (e) => {
        e.preventDefault();
        container.classList.remove('drag-over');
    });
});

function getDragAfterElement(container, y) {
    const items = [...container.querySelectorAll('.item:not(.dragging)')];

    return items.reduce((closest, child) => {
        const box = child.getBoundingClientRect();
        const offset = y - box.top - box.height / 2;

        if (offset < 0 && offset > closest.offset) {
            return { offset: offset, element: child };
        } else {
            return closest;
        }
    }, { offset: Number.NEGATIVE_INFINITY }).element;
}
</script>
Tip: The getDragAfterElement function is a powerful utility that finds the closest element below the cursor position. By comparing the mouse Y coordinate against the vertical midpoint of each child element, it determines exactly where the dragged item should be inserted. This same function works for both single-container reordering and multi-container transfers.

File Drop Zones

One of the most practical uses of the Drag and Drop API is creating file upload drop zones. Users can drag files directly from their desktop or file explorer into the browser, providing a much more intuitive experience than clicking a traditional file input button. The files are accessible through the dataTransfer.files property in the drop event.

Example: Complete File Drop Zone

<div id="file-drop-zone" class="file-drop">
    <p>Drag and drop files here</p>
    <p>or</p>
    <input type="file" id="file-input" multiple />
</div>
<div id="file-list"></div>

<script>
const fileDropZone = document.getElementById('file-drop-zone');
const fileInput = document.getElementById('file-input');
const fileList = document.getElementById('file-list');

// Prevent default drag behavior on the entire document
// to stop the browser from opening dropped files
document.addEventListener('dragover', (e) => e.preventDefault());
document.addEventListener('drop', (e) => e.preventDefault());

fileDropZone.addEventListener('dragenter', (e) => {
    e.preventDefault();
    fileDropZone.classList.add('active');
});

fileDropZone.addEventListener('dragover', (e) => {
    e.preventDefault();
    e.dataTransfer.dropEffect = 'copy';
});

fileDropZone.addEventListener('dragleave', (e) => {
    if (!fileDropZone.contains(e.relatedTarget)) {
        fileDropZone.classList.remove('active');
    }
});

fileDropZone.addEventListener('drop', (e) => {
    e.preventDefault();
    fileDropZone.classList.remove('active');

    const files = e.dataTransfer.files;
    handleFiles(files);
});

// Also handle file input for fallback
fileInput.addEventListener('change', (e) => {
    handleFiles(e.target.files);
});

function handleFiles(files) {
    for (const file of files) {
        const item = document.createElement('div');
        item.className = 'file-item';

        const sizeKB = (file.size / 1024).toFixed(1);
        item.innerHTML = `
            <strong>${file.name}</strong>
            <span>${file.type || 'Unknown type'}</span>
            <span>${sizeKB} KB</span>
        `;

        // Preview images
        if (file.type.startsWith('image/')) {
            const reader = new FileReader();
            reader.onload = (e) => {
                const img = document.createElement('img');
                img.src = e.target.result;
                img.style.maxWidth = '100px';
                item.appendChild(img);
            };
            reader.readAsDataURL(file);
        }

        fileList.appendChild(item);
    }
}
</script>
Important: Always add dragover and drop event listeners to the document that call preventDefault(). Without these, if the user accidentally drops a file outside your drop zone, the browser will navigate away from your page to display the file. This is a very common issue that frustrates users.

Accessibility Considerations

The native Drag and Drop API relies entirely on mouse interaction, which creates significant accessibility barriers. Users who navigate with keyboards, screen readers, or alternative input devices cannot use the native drag events. It is essential to provide alternative interaction methods alongside drag and drop.

Here are the key accessibility practices you should follow:

  • Provide keyboard-accessible alternatives such as buttons to move items up, down, or between containers.
  • Use ARIA attributes like aria-grabbed, aria-dropeffect, and role="listbox" to communicate the drag state to assistive technologies.
  • Add visible instructions that explain how to use both the drag-and-drop and keyboard methods.
  • Use ARIA live regions to announce changes when items are moved.
  • Ensure all interactive elements are focusable and operable with the keyboard.

Example: Accessible Drag and Drop with Keyboard Support

<ul id="accessible-list" role="listbox" aria-label="Reorderable list">
    <li role="option" tabindex="0" draggable="true" aria-grabbed="false">
        <span>Item 1</span>
        <button class="move-up" aria-label="Move Item 1 up">Up</button>
        <button class="move-down" aria-label="Move Item 1 down">Down</button>
    </li>
    <li role="option" tabindex="0" draggable="true" aria-grabbed="false">
        <span>Item 2</span>
        <button class="move-up" aria-label="Move Item 2 up">Up</button>
        <button class="move-down" aria-label="Move Item 2 down">Down</button>
    </li>
</ul>
<div aria-live="polite" id="announcer" class="sr-only"></div>

<script>
const announcer = document.getElementById('announcer');

// Keyboard support: Space to grab, arrows to move, Enter to drop
document.querySelectorAll('[role="option"]').forEach(item => {
    item.addEventListener('keydown', (e) => {
        if (e.key === ' ' || e.key === 'Enter') {
            e.preventDefault();
            const grabbed = item.getAttribute('aria-grabbed') === 'true';
            item.setAttribute('aria-grabbed', !grabbed);
            announcer.textContent = grabbed
                ? `${item.textContent.trim()} dropped`
                : `${item.textContent.trim()} grabbed. Use arrow keys to move.`;
        }

        if (item.getAttribute('aria-grabbed') === 'true') {
            if (e.key === 'ArrowUp' && item.previousElementSibling) {
                e.preventDefault();
                item.parentNode.insertBefore(item, item.previousElementSibling);
                announcer.textContent = `Moved up. Now at position ${getPosition(item)}.`;
                item.focus();
            }
            if (e.key === 'ArrowDown' && item.nextElementSibling) {
                e.preventDefault();
                item.parentNode.insertBefore(item.nextElementSibling, item);
                announcer.textContent = `Moved down. Now at position ${getPosition(item)}.`;
                item.focus();
            }
        }
    });
});

function getPosition(el) {
    return [...el.parentNode.children].indexOf(el) + 1;
}

// Button-based movement
document.querySelectorAll('.move-up').forEach(btn => {
    btn.addEventListener('click', (e) => {
        const item = e.target.closest('li');
        if (item.previousElementSibling) {
            item.parentNode.insertBefore(item, item.previousElementSibling);
            announcer.textContent = `${item.querySelector('span').textContent} moved up.`;
        }
    });
});

document.querySelectorAll('.move-down').forEach(btn => {
    btn.addEventListener('click', (e) => {
        const item = e.target.closest('li');
        if (item.nextElementSibling) {
            item.parentNode.insertBefore(item.nextElementSibling, item);
            announcer.textContent = `${item.querySelector('span').textContent} moved down.`;
        }
    });
});
</script>

Touch Device Fallbacks

The HTML5 Drag and Drop API has limited support on touch devices. While some mobile browsers support it with certain polyfills, the experience is inconsistent. For production applications that need to work on mobile devices, you should implement touch-based drag and drop using the Touch Events API as a fallback.

Example: Touch Event Fallback for Drag and Drop

<script>
class TouchDragDrop {
    constructor(container, itemSelector) {
        this.container = container;
        this.itemSelector = itemSelector;
        this.draggedItem = null;
        this.placeholder = null;
        this.offsetX = 0;
        this.offsetY = 0;

        this.init();
    }

    init() {
        this.container.querySelectorAll(this.itemSelector).forEach(item => {
            item.addEventListener('touchstart', this.onTouchStart.bind(this), { passive: false });
            item.addEventListener('touchmove', this.onTouchMove.bind(this), { passive: false });
            item.addEventListener('touchend', this.onTouchEnd.bind(this));
        });
    }

    onTouchStart(e) {
        this.draggedItem = e.target.closest(this.itemSelector);
        if (!this.draggedItem) return;

        const touch = e.touches[0];
        const rect = this.draggedItem.getBoundingClientRect();

        this.offsetX = touch.clientX - rect.left;
        this.offsetY = touch.clientY - rect.top;

        // Create a placeholder for the original position
        this.placeholder = this.draggedItem.cloneNode(true);
        this.placeholder.style.opacity = '0.3';
        this.draggedItem.parentNode.insertBefore(this.placeholder, this.draggedItem);

        // Style the dragged item for absolute positioning
        this.draggedItem.style.position = 'fixed';
        this.draggedItem.style.zIndex = '1000';
        this.draggedItem.style.width = rect.width + 'px';
        this.draggedItem.style.left = (touch.clientX - this.offsetX) + 'px';
        this.draggedItem.style.top = (touch.clientY - this.offsetY) + 'px';
    }

    onTouchMove(e) {
        if (!this.draggedItem) return;
        e.preventDefault();

        const touch = e.touches[0];
        this.draggedItem.style.left = (touch.clientX - this.offsetX) + 'px';
        this.draggedItem.style.top = (touch.clientY - this.offsetY) + 'px';

        // Find the element under the touch point
        this.draggedItem.style.display = 'none';
        const elementBelow = document.elementFromPoint(touch.clientX, touch.clientY);
        this.draggedItem.style.display = '';

        const targetItem = elementBelow?.closest(this.itemSelector);
        if (targetItem && targetItem !== this.placeholder) {
            const rect = targetItem.getBoundingClientRect();
            const midY = rect.top + rect.height / 2;

            if (touch.clientY < midY) {
                this.container.insertBefore(this.placeholder, targetItem);
            } else {
                this.container.insertBefore(this.placeholder, targetItem.nextSibling);
            }
        }
    }

    onTouchEnd(e) {
        if (!this.draggedItem || !this.placeholder) return;

        // Insert the dragged item at the placeholder position
        this.placeholder.parentNode.insertBefore(this.draggedItem, this.placeholder);
        this.placeholder.remove();

        // Reset styles
        this.draggedItem.style.position = '';
        this.draggedItem.style.zIndex = '';
        this.draggedItem.style.width = '';
        this.draggedItem.style.left = '';
        this.draggedItem.style.top = '';

        this.draggedItem = null;
        this.placeholder = null;
    }
}

// Initialize touch drag-and-drop
const list = document.getElementById('sortable-list');
new TouchDragDrop(list, 'li');
</script>
Tip: Use feature detection to choose between the native Drag and Drop API and the touch fallback. Check for 'ontouchstart' in window to detect touch devices. For the best user experience, you can support both by initializing the native API for mouse users and the touch fallback for touch users. Libraries like interact.js and SortableJS handle this cross-device compatibility automatically.

Building a Kanban Board with Drag and Drop

A kanban board is the quintessential drag-and-drop application. It features multiple columns (such as To Do, In Progress, and Done) where tasks can be dragged between columns and reordered within columns. This example combines all the concepts covered in this lesson into a complete, functional component.

Example: Complete Kanban Board

<div class="kanban-board" id="kanban">
    <div class="kanban-column" data-status="todo">
        <h3>To Do</h3>
        <div class="kanban-cards">
            <div class="kanban-card" draggable="true" data-id="1">
                <h4>Design homepage layout</h4>
                <span class="priority high">High</span>
            </div>
            <div class="kanban-card" draggable="true" data-id="2">
                <h4>Write API documentation</h4>
                <span class="priority medium">Medium</span>
            </div>
        </div>
    </div>

    <div class="kanban-column" data-status="in-progress">
        <h3>In Progress</h3>
        <div class="kanban-cards">
            <div class="kanban-card" draggable="true" data-id="3">
                <h4>Build user authentication</h4>
                <span class="priority high">High</span>
            </div>
        </div>
    </div>

    <div class="kanban-column" data-status="done">
        <h3>Done</h3>
        <div class="kanban-cards">
            <div class="kanban-card" draggable="true" data-id="4">
                <h4>Set up project repository</h4>
                <span class="priority low">Low</span>
            </div>
        </div>
    </div>
</div>

<script>
class KanbanBoard {
    constructor(boardEl) {
        this.board = boardEl;
        this.draggedCard = null;
        this.init();
    }

    init() {
        // Delegate drag events from the board
        this.board.addEventListener('dragstart', this.onDragStart.bind(this));
        this.board.addEventListener('dragend', this.onDragEnd.bind(this));

        // Set up each column as a drop zone
        this.board.querySelectorAll('.kanban-cards').forEach(column => {
            column.addEventListener('dragover', this.onDragOver.bind(this));
            column.addEventListener('dragenter', this.onDragEnter.bind(this));
            column.addEventListener('dragleave', this.onDragLeave.bind(this));
            column.addEventListener('drop', this.onDrop.bind(this));
        });
    }

    onDragStart(e) {
        const card = e.target.closest('.kanban-card');
        if (!card) return;

        this.draggedCard = card;
        e.dataTransfer.setData('text/plain', card.dataset.id);
        e.dataTransfer.effectAllowed = 'move';

        // Add visual feedback with a short delay so the ghost image
        // captures the original appearance
        requestAnimationFrame(() => {
            card.classList.add('dragging');
        });
    }

    onDragEnd(e) {
        if (this.draggedCard) {
            this.draggedCard.classList.remove('dragging');
            this.draggedCard = null;
        }

        // Remove all column highlights
        this.board.querySelectorAll('.kanban-cards').forEach(col => {
            col.classList.remove('column-highlight');
        });
    }

    onDragOver(e) {
        e.preventDefault();
        e.dataTransfer.dropEffect = 'move';

        const column = e.target.closest('.kanban-cards');
        if (!column) return;

        const afterElement = this.getInsertionPoint(column, e.clientY);

        if (afterElement) {
            column.insertBefore(this.draggedCard, afterElement);
        } else {
            column.appendChild(this.draggedCard);
        }
    }

    onDragEnter(e) {
        e.preventDefault();
        const column = e.target.closest('.kanban-cards');
        if (column) {
            column.classList.add('column-highlight');
        }
    }

    onDragLeave(e) {
        const column = e.target.closest('.kanban-cards');
        if (column && !column.contains(e.relatedTarget)) {
            column.classList.remove('column-highlight');
        }
    }

    onDrop(e) {
        e.preventDefault();
        const column = e.target.closest('.kanban-cards');
        if (column) {
            column.classList.remove('column-highlight');
        }

        // Get the new status from the column
        const newStatus = column.closest('.kanban-column').dataset.status;
        const cardId = e.dataTransfer.getData('text/plain');

        console.log(`Card ${cardId} moved to ${newStatus}`);
        // Here you would typically send an API request to update
        // the task status on the server
    }

    getInsertionPoint(column, y) {
        const cards = [...column.querySelectorAll('.kanban-card:not(.dragging)')];

        return cards.reduce((closest, card) => {
            const box = card.getBoundingClientRect();
            const offset = y - box.top - box.height / 2;

            if (offset < 0 && offset > closest.offset) {
                return { offset, element: card };
            }
            return closest;
        }, { offset: Number.NEGATIVE_INFINITY }).element;
    }
}

// Initialize the kanban board
const kanban = new KanbanBoard(document.getElementById('kanban'));
</script>

Best Practices and Common Pitfalls

After working with the Drag and Drop API extensively, several best practices emerge that will save you time and prevent frustrating bugs.

  • Always call preventDefault() in both dragover and drop handlers. The dragover default prevents drops; the drop default may cause the browser to navigate to the dropped data.
  • Use event delegation instead of attaching listeners to individual draggable elements. Attach the dragstart and dragend listeners to the parent container and use e.target.closest() to find the dragged element. This handles dynamically added elements automatically.
  • Track drag counters for dragenter and dragleave to prevent flickering. Child elements cause extra enter and leave events.
  • Use requestAnimationFrame for visual changes in dragstart. Adding classes directly in dragstart can affect the ghost image the browser captures.
  • Set data in dragstart only. The setData() method only works during dragstart. Attempting to call it in other events will have no effect.
  • Security restrictions: You can only read getData() during the drop event. In dragover and dragenter, you can check types but not read the actual data.
  • Provide alternatives for accessibility and touch devices. Never make drag and drop the only way to accomplish a task.

Practice Exercise

Build a complete task organizer application with drag and drop. Create three columns labeled "Backlog," "In Progress," and "Completed." Add a form at the top that allows users to create new task cards with a title, description, and priority level (low, medium, high). Each card should be draggable between all three columns and reorderable within each column. Add a counter at the top of each column showing the number of tasks. Include keyboard-based movement buttons (left arrow and right arrow) on each card as an accessible alternative to dragging. When a card is moved to the "Completed" column, add a visual strikethrough effect to its title. Implement a file attachment drop zone on each card where users can drag a file from their desktop to attach it to the task. Finally, add a touch fallback so the kanban board works on mobile devices. Test your implementation by creating at least five tasks and moving them between all columns using both mouse drag and keyboard buttons.