Using the CocoMarkdown project as a worked example, this post summarizes complete implementations for two drag-and-drop scenarios in Tauri (v2) apps, for other projects to reference.
Core principle: never use the HTML5 Drag and Drop API for in-app dragging, because Tauri’s WebView2 (Windows) intercepts native drag events — the cursor turns into a “not allowed” icon and no amount of preventDefault() fixes it.
2. Scenario 1: In-App Drag & Drop
2.1 Why not the HTML5 DnD API
On Windows WebView2, HTML5 Drag and Drop events get intercepted at the native layer:
Calling e.preventDefault() in dragover cannot stop the cursor from turning into the forbidden icon
dataTransfer.dropEffect settings have no effect
Custom MIME types don’t help either
Even intercepting the DragDrop event in Rust via on_window_event and calling prevent_default() doesn’t work (Tauri v2’s DragDropEvent has no prevent_default method)
Conclusion: the HTML5 DnD API does not work inside Tauri WebView2 — use Mouse events instead.
2.2 Mouse-Event Implementation
Overall approach
mousedown: record the drag source element and start coordinates
mousemove: once movement passes a threshold, activate the drag, show a ghost element following the cursor, highlight the target region
mouseup: complete the drop, run the business logic
Extras: ESC to cancel, prevent accidental click after a drag
// ── State ── let dragSrcPath = null; // drag source path let dragOverItem = null; // currently hovered target element let dragSrcElement = null; // drag source DOM element let isDragging = false; // drag in progress? let wasDragging = false; // a drag just finished (to swallow click) let dragStartX = 0; // drag start X let dragStartY = 0; // drag start Y let dragGhost = null; // ghost element following the cursor
// ── Reset drag state ── functionclearDragState() { if (dragSrcElement) dragSrcElement.classList.remove('dragging'); if (dragOverItem) dragOverItem.classList.remove('drag-over'); dragSrcPath = null; dragOverItem = null; dragSrcElement = null; isDragging = false; removeDragGhost(); document.body.classList.remove('explorer-dragging'); }
// ── mousedown: record the source ── container.addEventListener('mousedown', (e) => { const item = e.target.closest('.tree-item[data-path]'); if (!item) return; if (e.button !== 0) return; // left button only dragSrcPath = item.dataset.path; dragSrcElement = item; dragStartX = e.clientX; dragStartY = e.clientY; isDragging = false; wasDragging = false; });
// ── mousemove: drag in progress ── document.addEventListener('mousemove', (e) => { if (!dragSrcPath || !dragSrcElement) return;
// Movement threshold (prevents clicks from triggering drags) const dx = e.clientX - dragStartX; const dy = e.clientY - dragStartY; if (!isDragging) { if (Math.abs(dx) < 4 && Math.abs(dy) < 4) return; isDragging = true; dragSrcElement.classList.add('dragging'); document.body.classList.add('explorer-dragging'); const label = dragSrcElement.querySelector('.tree-label')?.textContent || ''; createDragGhost(label); }
// move the ghost moveDragGhost(e.clientX, e.clientY);
// hit-test the drop target (elementFromPoint sees through the ghost) const target = document.elementFromPoint(e.clientX, e.clientY) ?.closest('.tree-item[data-is-dir="true"]');
if (!srcPath || !destDir) return; if (srcPath === destDir) return;
// guard against dropping a folder into its own subdirectory if (destDir.startsWith(srcPath + '\\') || destDir.startsWith(srcPath + '/')) { showToast('Cannot move a folder into its own subfolder'); return; }
// perform the move awaithandleExplorerDrop(srcPath, destDir); });
// ── swallow the click that fires after a drag ── container.addEventListener('click', (e) => { if (wasDragging) { e.stopPropagation(); e.preventDefault(); wasDragging = false; } }, true);
Drag activates only after moving 4px+ from mousedown, so clicks never trigger drags
pointer-events: none
The ghost must set this, otherwise elementFromPoint hits the ghost instead of the target
wasDragging flag
A click still fires after mouseup — intercept it in the capture phase
Body-level cursor
Add a class to body during drags for cursor: grabbing, so the pointer is correct everywhere
Subdirectory guard
Prevent dropping a folder into its own subdirectory (infinite loop)
Name-collision check
Backend verifies no same-named file/folder exists at the destination
3. Scenario 2: External Files Dropped Into the App
3.1 Dual-strategy architecture
Dropping external files needs both strategies at once:
Strategy A (primary): Tauri native events — real file paths via tauri://drag-enter / tauri://drag-leave / tauri://drag-drop
Strategy B (supporting): HTML5 DnD events — UI visual feedback (highlight borders) backing up strategy A
Why both? Tauri’s native events only provide drag-enter / drag-leave / drag-drop — no dragover — but the UI highlight needs continuous updates during dragover.
3.2 Rust: forwarding native drag events
Register an on_window_event handler in the Tauri builder to forward native drag events to the frontend: