Implementing Drag and Drop in Tauri Apps: A Complete Guide

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.


Contents

  1. Scenario Overview
  2. Scenario 1: In-App Drag & Drop
  3. Scenario 2: External Files Dropped Into the App
  4. Common Pitfalls
  5. Full Code Reference

1. Scenario Overview

Scenario Drag source Drop target Recommended approach
In-app DnD DOM element inside the app DOM element inside the app Mouse events (mousedown/mousemove/mouseup)
External files OS file manager Region inside the app Tauri native events + HTML5 DnD assist

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

  1. mousedown: record the drag source element and start coordinates
  2. mousemove: once movement passes a threshold, activate the drag, show a ghost element following the cursor, highlight the target region
  3. mouseup: complete the drop, run the business logic
  4. Extras: ESC to cancel, prevent accidental click after a drag

JavaScript implementation

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
// ── 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

// ── Ghost element management ──
function createDragGhost(label) {
dragGhost = document.createElement('div');
dragGhost.className = 'tree-drag-ghost';
dragGhost.textContent = label;
document.body.appendChild(dragGhost);
}

function moveDragGhost(x, y) {
if (!dragGhost) return;
dragGhost.style.left = (x + 12) + 'px';
dragGhost.style.top = (y + 12) + 'px';
}

function removeDragGhost() {
if (dragGhost) { dragGhost.remove(); dragGhost = null; }
}

// ── Reset drag state ──
function clearDragState() {
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 (dragOverItem && dragOverItem !== target) {
dragOverItem.classList.remove('drag-over');
}
if (target && target.dataset.path !== dragSrcPath) {
target.classList.add('drag-over');
dragOverItem = target;
} else if (dragOverItem) {
dragOverItem.classList.remove('drag-over');
dragOverItem = null;
}
});

// ── mouseup: complete the drop ──
document.addEventListener('mouseup', async (e) => {
if (!dragSrcPath || !dragSrcElement) {
dragSrcPath = null;
dragSrcElement = null;
return;
}
if (!isDragging) {
dragSrcPath = null;
dragSrcElement = null;
return;
}

wasDragging = true;
const destDir = dragOverItem?.dataset.path;
const srcPath = dragSrcPath;
clearDragState();

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
await handleExplorerDrop(srcPath, destDir);
});

// ── swallow the click that fires after a drag ──
container.addEventListener('click', (e) => {
if (wasDragging) {
e.stopPropagation();
e.preventDefault();
wasDragging = false;
}
}, true);

// ── ESC cancels the drag ──
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && isDragging) {
clearDragState();
wasDragging = false;
}
});

CSS

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
/* drag source: translucent */
.tree-item.dragging {
opacity: 0.4;
}

/* drop target: highlighted border */
.tree-item.drag-over {
background: rgba(108, 99, 255, 0.2);
outline: 1px dashed var(--accent);
outline-offset: -1px;
}

/* while dragging: cursor */
body.explorer-dragging,
body.explorer-dragging .tree-item {
cursor: grabbing !important;
}

/* ghost label following the cursor */
.tree-drag-ghost {
position: fixed;
z-index: 9999;
pointer-events: none; /* key: lets elementFromPoint see through it */
background: var(--accent);
color: #fff;
padding: 4px 10px;
border-radius: 4px;
font-size: 12px;
white-space: nowrap;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
opacity: 0.9;
}

Rust backend: the move command

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#[tauri::command]
fn move_item(src_path: String, dest_dir: String) -> Result<String, String> {
let src = PathBuf::from(&src_path);
let dest = PathBuf::from(&dest_dir);

if !src.exists() {
return Err("Source does not exist".to_string());
}
if !dest.is_dir() {
return Err("Destination is not a directory".to_string());
}

let file_name = src.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
let dest_full = dest.join(&file_name);

if dest_full.exists() {
return Err("A file/folder with that name already exists at destination".to_string());
}

fs::rename(&src, &dest_full)
.map_err(|e| format!("Move failed: {}", e))?;

Ok(dest_full.to_string_lossy().to_string())
}

2.3 Key Design Points

Point Detail
Movement threshold 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:

1
2
3
4
5
6
7
8
tauri::Builder::default()
.on_window_event(|window, event| {
if let tauri::WindowEvent::DragDrop(drop_event) = event {
if let tauri::DragDropEvent::Drop { paths, position: _ } = drop_event {
let _ = window.emit("tauri://drag-drop", paths);
}
}
})

Note: Tauri v2’s DragDropEvent has no prevent_default() method — don’t try to call it.

3.3 Frontend: Tauri events + HTML5 DnD assist

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
function setupExternalDragDrop() {
const targetPanel = document.querySelector('.explorer-panel');
const editorPanel = document.querySelector('.editor-panel');

// ── Strategy A: Tauri native events ──
if (window.__TAURI__?.event?.listen) {
window.__TAURI__.event.listen('tauri://drag-enter', () => {
targetPanel?.classList.add('explorer-drag-over');
});

window.__TAURI__.event.listen('tauri://drag-leave', () => {
targetPanel?.classList.remove('explorer-drag-over');
});

window.__TAURI__.event.listen('tauri://drag-drop', (event) => {
targetPanel?.classList.remove('explorer-drag-over');
const paths = Array.isArray(event.payload)
? event.payload
: (event.payload?.paths || []);
if (paths.length > 0) {
handleDroppedFiles(paths);
}
});
}

// ── Strategy B: HTML5 DnD (visual feedback + fallback paths) ──
if (targetPanel) {
['dragenter', 'dragover'].forEach((name) =>
targetPanel.addEventListener(name, (e) => {
e.preventDefault();
e.stopPropagation();
targetPanel.classList.add('explorer-drag-over');
})
);
['dragleave', 'drop'].forEach((name) =>
targetPanel.addEventListener(name, (e) => {
e.preventDefault();
e.stopPropagation();
targetPanel.classList.remove('explorer-drag-over');
})
);
targetPanel.addEventListener('drop', async (e) => {
const paths = [];
if (e.dataTransfer?.files) {
for (const f of e.dataTransfer.files) {
if (f.path) paths.push(f.path);
}
}
if (paths.length > 0) {
await handleDroppedFiles(paths);
}
});
}

// ── Editor area: only .md files accepted ──
if (editorPanel) {
const MD_RE = /\.(md|markdown|mdown|mkd|mkdn|mdwn|mdtxt|text|mdtext|Rmd)$/i;
['dragenter', 'dragover'].forEach((ev) =>
editorPanel.addEventListener(ev, (e) => {
if (!e.dataTransfer.types.includes('Files')) return;
e.preventDefault();
e.stopPropagation();
editorPanel.classList.add('editor-drag-over');
})
);
['dragleave', 'drop'].forEach((ev) =>
editorPanel.addEventListener(ev, (e) => {
e.preventDefault();
e.stopPropagation();
editorPanel.classList.remove('editor-drag-over');
})
);
editorPanel.addEventListener('drop', async (e) => {
if (!e.dataTransfer?.files || e.dataTransfer.files.length === 0) return;
for (const f of e.dataTransfer.files) {
if (MD_RE.test(f.name) && f.path) {
await openFile(f.path);
break;
}
}
});
}
}

3.4 CSS: drop-zone visual feedback

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
/* explorer drop highlight */
.panel-content.explorer-drag-over {
position: relative;
}

.panel-content.explorer-drag-over::after {
content: '';
position: absolute;
inset: 0;
z-index: 10;
pointer-events: none;
border: 2px dashed var(--accent);
border-radius: var(--radius-sm);
background: rgba(108, 99, 255, 0.06);
animation: dragPulse 1.5s ease-in-out infinite;
}

/* editor drop highlight */
.editor-panel.editor-drag-over::after {
content: '';
position: absolute;
inset: 0;
z-index: 10;
pointer-events: none;
border: 2px dashed var(--accent);
border-radius: var(--radius-sm);
background: rgba(108, 99, 255, 0.06);
animation: dragPulse 1.5s ease-in-out infinite;
}

@keyframes dragPulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}

3.5 Tauri capability configuration

Make sure capabilities includes the event-listening permissions:

1
2
3
4
5
6
{
"permissions": [
"core:event:default",
"core:event:allow-listen"
]
}

4. Common Pitfalls

4.1 WebView2 intercepts HTML5 DnD events

Symptom Cause Fix
Cursor turns into the forbidden icon WebView2 intercepts drags at the native layer; preventDefault() is useless Use Mouse events for in-app dragging
drop never fires WebView2 treats the drag as files being dropped into the app Get paths from Tauri native events
dataTransfer.getData() returns empty WebView2 security restrictions Get paths from the Tauri event payload

4.2 elementFromPoint hits the ghost element

While the ghost follows the cursor, document.elementFromPoint(x, y) returns the ghost itself instead of the target underneath.

Fix: the ghost must set pointer-events: none.

4.3 Stray click after a drag

The browser still fires click after mouseup, accidentally opening the file/folder you just dropped.

Fix: use the wasDragging flag to intercept the click in the capture phase.

4.4 File.path may be undefined

In WebView2, the .path property of HTML5 DnD File objects may be undefined — no filesystem path available.

Fix: prefer Tauri native events (strategy A) for paths; HTML5 DnD is only visual feedback plus a fallback.

4.5 Tauri v2’s DragDropEvent has no prevent_default

Tauri v1’s DragDropEvent had prevent_default(); it was removed in v2. Don’t call it inside on_window_event.

4.6 External drops vs in-app drags conflicting

Mouse-event in-app dragging and HTML5 DnD / Tauri-native external drops don’t interfere, but note:

  • Mouse mousemove does not fire during an HTML5 DnD operation (the browser suppresses mouse events mid-DnD)
  • So both mechanisms coexist safely

5. Full Code Reference

Files involved in the CocoMarkdown project:

File Contents
src/app.js (L1211-L1315) In-app drag: Mouse-event implementation
src/app.js (L2225-L2320) External file drops: dual-strategy implementation
src/app.js (L2435-L2474) Post-drop file move handling
src/styles.css (L694-L793) Drag-related CSS
src-tauri/src/lib.rs (L808-L837) Rust move_item command
src-tauri/src/lib.rs (L871-L878) Rust on_window_event forwarding
src-tauri/capabilities/default.json Event-listening permissions

Summary

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
┌─────────────────────────────────────────────────────┐
│ Tauri Drag & Drop Decision Tree │
├─────────────────────────────────────────────────────┤
│ │
│ What is the drag source? │
│ │ │
│ ├── DOM element inside the app │
│ │ └── Use Mouse events (mousedown/move/up) │
│ │ - No HTML5 DnD (WebView2 intercepts it) │
│ │ - ghost element: pointer-events: none │
│ │ - wasDragging swallows stray clicks │
│ │ │
│ └── External OS file │
│ └── Tauri native events + HTML5 DnD assist │
│ - Tauri events give real file paths │
│ - HTML5 DnD gives dragover feedback │
│ - on_window_event forwards drag-drop │
│ │
└─────────────────────────────────────────────────────┘