Implementing Drag-and-Drop Files from System to Desktop Application
Dragging files from file manager into application window is a basic scenario for productivity tools. In Electron this works via standard HTML5 drag-and-drop events (renderer) plus optional native handling (main). In Tauri — via tauri-plugin-drag-drop.
Basic Handler in Renderer
HTML5 Drag-and-Drop API works in Electron renderer like in regular browser. Main difference: when dragging files from OS, event.dataTransfer.files contains File objects with native file paths.
// src/hooks/useFileDrop.ts
import { useRef, useState, useCallback } from 'react'
export interface DroppedFile {
name: string
path: string
size: number
type: string
lastModified: number
}
interface UseFileDropOptions {
accept?: string[]
maxFiles?: number
maxSizeBytes?: number
onDrop: (files: DroppedFile[]) => void
onError?: (error: string) => void
}
export function useFileDrop(options: UseFileDropOptions) {
const [isDragging, setIsDragging] = useState(false)
const dragCounter = useRef(0)
const validateFile = useCallback((file: File): string | null => {
if (options.accept && options.accept.length > 0) {
const ext = '.' + file.name.split('.').pop()?.toLowerCase()
if (!options.accept.includes(ext)) {
return `Format ${ext} not supported`
}
}
if (options.maxSizeBytes && file.size > options.maxSizeBytes) {
const mb = (options.maxSizeBytes / 1024 / 1024).toFixed(1)
return `File exceeds ${mb} MB`
}
return null
}, [options.accept, options.maxSizeBytes])
const handleDragEnter = useCallback((e: DragEvent) => {
e.preventDefault()
dragCounter.current++
setIsDragging(true)
}, [])
const handleDragLeave = useCallback((e: DragEvent) => {
e.preventDefault()
dragCounter.current--
if (dragCounter.current === 0) {
setIsDragging(false)
}
}, [])
const handleDrop = useCallback((e: DragEvent) => {
e.preventDefault()
setIsDragging(false)
dragCounter.current = 0
const files = Array.from(e.dataTransfer?.files ?? [])
if (options.maxFiles && files.length > options.maxFiles) {
options.onError?.(`Max ${options.maxFiles} files allowed`)
return
}
const valid: DroppedFile[] = []
for (const file of files) {
const error = validateFile(file)
if (error) {
options.onError?.(error)
continue
}
valid.push({
name: file.name,
path: (file as any).path ?? '',
size: file.size,
type: file.type,
lastModified: file.lastModified,
})
}
if (valid.length > 0) {
options.onDrop(valid)
}
}, [validateFile, options])
return {
isDragging,
dropProps: {
onDragEnter: handleDragEnter,
onDragLeave: handleDragLeave,
onDragOver: (e: DragEvent) => {
e.preventDefault()
e.dataTransfer.dropEffect = 'copy'
},
onDrop: handleDrop,
},
}
}
Drop Zone Component
// src/components/DropZone.tsx
interface DropZoneProps {
accept?: string[]
maxFiles?: number
maxSizeMb?: number
onFilesDropped: (files: DroppedFile[]) => void
}
export function DropZone({ accept, maxFiles, maxSizeMb = 100, onFilesDropped }: DropZoneProps) {
const { isDragging, dropProps } = useFileDrop({
accept,
maxFiles,
maxSizeBytes: maxSizeMb * 1024 * 1024,
onDrop: onFilesDropped,
})
return (
<div
{...dropProps}
className={`relative p-8 border-2 border-dashed rounded-lg transition-all
${isDragging ? 'border-blue-500 bg-blue-50' : 'border-gray-300'}`}
>
{isDragging && (
<div className="absolute inset-0 flex items-center justify-center bg-blue-500/10">
<p className="text-blue-700 font-medium">Drop files here</p>
</div>
)}
</div>
)
}
File Processing via IPC
// preload/index.ts
contextBridge.exposeInMainWorld('fileSystem', {
readFile: (filePath: string) =>
ipcRenderer.invoke('fs:read-file', filePath),
copyToWorkspace: (sourcePath: string, destName: string) =>
ipcRenderer.invoke('fs:copy-to-workspace', sourcePath, destName),
})
// main/ipc-handlers/file-system.ts
const workspacePath = path.join(app.getPath('userData'), 'workspace')
ipcMain.handle('fs:read-file', async (_event, filePath: string) => {
try {
const resolved = path.resolve(filePath)
const buffer = await fs.readFile(resolved)
return { ok: true, data: buffer.toString('base64'), size: buffer.length }
} catch (e: any) {
return { ok: false, error: e.message }
}
})
ipcMain.handle('fs:copy-to-workspace', async (_event, sourcePath: string, destName: string) => {
try {
await fs.mkdir(workspacePath, { recursive: true })
const safeName = path.basename(destName).replace(/[^a-zA-Z0-9._-]/g, '_')
const destPath = path.join(workspacePath, safeName)
await fs.copyFile(sourcePath, destPath)
return { ok: true, destPath }
} catch (e: any) {
return { ok: false, error: e.message }
}
})
Dragging Directories
When user drags a folder, e.dataTransfer.files contains it as a single File. Use webkitGetAsEntry() to get contents:
// src/utils/directory-reader.ts
export async function readDroppedItems(dataTransfer: DataTransfer): Promise<FileEntry[]> {
const entries: FileSystemEntry[] = []
for (let i = 0; i < dataTransfer.items.length; i++) {
const entry = dataTransfer.items[i].webkitGetAsEntry()
if (entry) entries.push(entry)
}
const files: FileEntry[] = []
await Promise.all(entries.map(entry => traverseEntry(entry, '', files)))
return files
}
async function traverseEntry(
entry: FileSystemEntry,
relativePath: string,
result: FileEntry[]
): Promise<void> {
if (entry.isFile) {
const file = await getFile(entry as FileSystemFileEntry)
result.push({
path: (file as any).path ?? '',
relativePath: relativePath + file.name,
file,
})
} else if (entry.isDirectory) {
const reader = (entry as FileSystemDirectoryEntry).createReader()
const entries = await readAllEntries(reader)
await Promise.all(
entries.map(e => traverseEntry(e, relativePath + entry.name + '/', result))
)
}
}
Basic DropZone with validation: 4–6 hours. With directory support, progress, IPC handling, path security, tests: 2–3 days.







