"I've told my AI 10 times and drag & drop STILL doesn't work" β the cure.
VSCode Β· Cursor Β· Windsurf Β· Terminal Β· Claude Code Β· Copilot
You: "Build me a drag and drop file upload"
AI: "Done! Here you go β¨"
You: *drags file over*
You: ...
You: Nothing happens.
You: "It doesn't work."
AI: "Oh sorry! I forgot preventDefault!"
You: *tries again*
You: Still broken.
AI: "Added stopPropagation too!"
You: *10th message*
You: "WHY. WON'T. THIS. WORK."
DropItLikeItsHot was built for exactly that moment.
Instead of arguing with your AI for the 10th time, just copy-paste from here and move on with your life. π€β¬οΈ
A copy-paste reference for vibe coders who keep getting stuck on drag & drop.
Every pattern here was extracted from production code that actually works. Not theory. Not tutorials. Battle-tested patterns.
| Pattern | Description | File |
|---|---|---|
| π΅ File Drop | Basic drag & drop for audio, images, any files | patterns/file-drop.jsx |
| π Folder Select | Select an entire folder to batch-add files | patterns/folder-select.jsx |
| π― Combo Upload | Drag & drop + file picker + folder picker (the holy trinity) | patterns/combo-upload.jsx |
| πΎ IndexedDB Storage | Store large files in the browser (bye bye 5MB localStorage limit) | patterns/indexeddb-storage.jsx |
| π‘οΈ Modal Drag Fix | Fix the infuriating "modal closes when I select text" bug | patterns/modal-drag-fix.jsx |
| π¦ Backup & Restore | Export IndexedDB data to JSON and import it back | patterns/backup-restore.jsx |
| β¬οΈ Smart Download | Download files with auto-formatted filenames | patterns/smart-download.jsx |
// Just copy-paste this. Seriously. This is it.
const DropZone = ({ onFilesDropped }) => {
const [isDragOver, setIsDragOver] = React.useState(false);
return (
<div
style={{
border: `2px dashed ${isDragOver ? '#f59e0b' : '#4b5563'}`,
borderRadius: '16px',
padding: '40px',
textAlign: 'center',
background: isDragOver ? 'rgba(245, 158, 11, 0.1)' : 'transparent',
transition: 'all 0.2s ease',
}}
// π₯ These 4 handlers are EVERYTHING. Miss one and it breaks.
onDragOver={(e) => {
e.preventDefault(); // β Without this, drop is BLOCKED by browser
e.stopPropagation(); // β Without this, parent elements steal the event
setIsDragOver(true);
}}
onDragEnter={(e) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(true);
}}
onDragLeave={(e) => {
e.stopPropagation();
// β Without this check, the zone flickers when hovering over children
if (!e.currentTarget.contains(e.relatedTarget)) {
setIsDragOver(false);
}
}}
onDrop={(e) => {
e.preventDefault(); // β Without this, the browser OPENS the file
e.stopPropagation();
setIsDragOver(false);
const files = Array.from(e.dataTransfer.files);
onFilesDropped(files);
}}
>
<p>{isDragOver ? 'π₯ Drop it!' : 'π Drag files here'}</p>
</div>
);
};β Broken β
Working
βββββββββββββββββββββ βββββββββββββββββββββ
onDrop={(e) => { onDrop={(e) => {
e.preventDefault(); β REQUIRED!
e.stopPropagation(); β REQUIRED!
handleDrop(e); handleDrop(e);
}} }}
No onDragOver handler onDragOver={(e) => {
e.preventDefault(); β Without this,
}} drop is IMPOSSIBLE!
When you ask AI to "build file upload," it usually gives you ONE method. In reality, you need all three:
// π― You need 2 refs (one for files, one for folders)
const fileInputRef = React.useRef(null);
const folderInputRef = React.useRef(null);
// Button area
<div style={{ display: 'flex', gap: '12px' }}>
{/* Drop zone */}
<DropZone onFilesDropped={handleFiles} />
{/* π΅ Individual file picker */}
<button onClick={() => fileInputRef.current?.click()}>
π΅ Select Files
</button>
{/* π Entire folder picker */}
<button onClick={() => folderInputRef.current?.click()}>
π Select Folder
</button>
</div>
{/* β οΈ You MUST use 2 separate inputs! webkitdirectory makes it folder-only */}
<input
ref={fileInputRef}
type="file"
multiple
accept=".mp3,.wav,.m4a,.flac"
style={{ display: 'none' }}
onChange={(e) => handleFiles(Array.from(e.target.files))}
/>
<input
ref={folderInputRef}
type="file"
webkitdirectory=""
multiple
style={{ display: 'none' }}
onChange={(e) => handleFiles(Array.from(e.target.files))}
/>π‘ What AI gets wrong: It puts
webkitdirectoryon the file input β Now you can ONLY select folders. Always use 2 separate inputs!
This bug will drive you absolutely insane. You try to select text inside a modal, and the whole modal just... closes.
β Common mistake (onClick to close)
ββββββββββββββββββββββββββββββββββ
<div className="backdrop" onClick={() => closeModal()}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<input value={text} /> β Drag to select text = MODAL CLOSES!
</div>
</div>
Why: text drag β mouseup fires on backdrop β triggers click β modal closes
// β
The fix: mousedown + mouseup combo
let backdropMouseDown = false;
<div className="backdrop"
onMouseDown={(e) => {
// Only track clicks that START on the backdrop
backdropMouseDown = (e.target === e.currentTarget);
}}
onMouseUp={(e) => {
// Only close if BOTH mousedown AND mouseup happened on backdrop
if (backdropMouseDown && e.target === e.currentTarget) {
closeModal();
}
backdropMouseDown = false;
}}
>
<div className="modal"
onMouseDown={(e) => e.stopPropagation()}
onMouseUp={(e) => e.stopPropagation()}
>
<input value={text} /> {/* β Now you can safely drag to select! */}
</div>
</div>localStorage caps out at 5MB β useless for audio, images, or video. IndexedDB handles hundreds of MBs.
const FileDB = {
DB_NAME: 'MyFilesDB',
STORE_NAME: 'files',
open() {
return new Promise((resolve, reject) => {
const req = indexedDB.open(this.DB_NAME, 1);
req.onupgradeneeded = () => {
req.result.createObjectStore(this.STORE_NAME, { keyPath: 'id' });
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
},
async saveFile(id, file) {
const db = await this.open();
const buffer = await file.arrayBuffer();
return new Promise((resolve, reject) => {
const tx = db.transaction(this.STORE_NAME, 'readwrite');
tx.objectStore(this.STORE_NAME).put({
id, data: buffer, type: file.type, name: file.name
});
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
},
async getFileURL(id) {
const db = await this.open();
return new Promise((resolve, reject) => {
const tx = db.transaction(this.STORE_NAME, 'readonly');
const req = tx.objectStore(this.STORE_NAME).get(id);
req.onsuccess = () => {
if (!req.result) return resolve(null);
const blob = new Blob([req.result.data], { type: req.result.type });
resolve(URL.createObjectURL(blob));
};
req.onerror = () => reject(req.error);
});
},
};// πΎ Backup: IndexedDB β base64 β JSON download
const handleBackup = async (items) => {
const backup = { version: 1, exportedAt: new Date().toISOString(), items: [] };
for (const item of items) {
const entry = { ...item };
delete entry.fileUrl; // blob URLs can't be saved
const db = await FileDB.open();
const record = await new Promise((resolve, reject) => {
const tx = db.transaction(FileDB.STORE_NAME, 'readonly');
const req = tx.objectStore(FileDB.STORE_NAME).get(item.id);
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
if (record?.data) {
const blob = new Blob([record.data], { type: record.type });
const reader = new FileReader();
entry.audioData = await new Promise((resolve) => {
reader.onload = () => resolve(reader.result);
reader.readAsDataURL(blob);
});
}
backup.items.push(entry);
}
const blob = new Blob([JSON.stringify(backup)], { type: 'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `backup_${new Date().toISOString().slice(0,10)}.json`;
a.click();
};
// π₯ Restore: JSON β base64 β IndexedDB
const handleRestore = async (file, existingItems) => {
const text = await file.text();
const backup = JSON.parse(text);
for (const entry of backup.items) {
if (existingItems.some(it => it.id === entry.id)) continue; // skip dupes
if (entry.audioData) {
const resp = await fetch(entry.audioData); // base64 β blob
const blob = await resp.blob();
await FileDB.saveFile(entry.id, blob);
entry.fileUrl = URL.createObjectURL(blob);
delete entry.audioData;
}
// β add to your state
}
};// Download with meaningful filenames instead of "blob:http://..."
const handleDownload = (item) => {
if (!item.fileUrl) return;
const ext = (item.fileName || '').match(/\.[^.]+$/)?.[0] || '.mp3';
// Strip characters that aren't allowed in filenames
const safe = (str) => (str || 'Unknown').replace(/[<>:"/\\|?*]/g, '_');
const a = document.createElement('a');
a.href = item.fileUrl;
a.download = `${safe(item.title)}_${safe(item.artist)}_${safe(item.label)}${ext}`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
};| # | Mistake | Symptom | Fix |
|---|---|---|---|
| 1 | Missing preventDefault() on onDragOver |
Drop literally doesn't work | Add preventDefault to all 4 drag events |
| 2 | Using one input for both files & folders | Can only select folders OR files, not both | Use 2 separate <input> elements |
| 3 | Using onClick to close modals |
Modal closes when you try to select text | Use onMouseDown + onMouseUp combo |
| 4 | Putting ref'd input inside conditional render | Ref is null in other views, button does nothing | Place inputs where they always render |
| 5 | No contains(relatedTarget) check in onDragLeave |
Drop zone flickers like a disco ball | Check if mouse actually left the zone |
After your AI builds drag & drop, verify these before you lose your mind:
- Does
onDragOverhavee.preventDefault()? - Does
onDrophave bothe.preventDefault()ANDe.stopPropagation()? - Does
onDragLeavecheckcontains(relatedTarget)? - Are file input and folder input separate elements?
- Is the input ref placed outside any conditional rendering?
- If there's a modal, does it use
onMouseDown/onMouseUpinstead ofonClick?
All boxes checked? Congratulations, you have a working drag & drop. π
These patterns are extracted from a production music demo management system where they handle:
- Drag & drop audio file uploads (MP3, WAV, FLAC, M4A)
- IndexedDB storage for dozens of audio files (100MB+ total)
- JSON backup & restore for data protection
- Auto-formatted filenames for music industry pitching
Found another drag & drop trap while vibe coding? Send a PR! Save someone else from the same pain. π
MIT License β Copy-paste to your heart's content. That's literally why this exists.
Made with π€ frustration and π₯ determination
"One copy-paste from here beats 10 conversations with your AI"