Chrome Extension Drag And Drop — Best Practices
36 min readDrag and Drop in Extensions
Overview
Drag and drop brings natural, tactile interaction to extension UIs — sortable bookmark lists in popups, file uploads into side panels, and content script overlays that let users drag page elements into the extension. But extensions add complexity: popups live in isolated windows, content scripts share the DOM with host pages, and cross-context communication requires message passing. This guide covers practical drag-and-drop patterns for every extension surface, from basic sortable lists to accessible keyboard alternatives.
Extension Drag-and-Drop Architecture
┌──────────────────────────────────────────────────────┐
│ Web Page (Content Script) │
│ │
│ ┌──────────┐ drag ┌──────────────┐ │
│ │ Draggable│ ──────> │ Drop Overlay │ │
│ │ Element │ │ (injected) │ │
│ └──────────┘ └──────┬───────┘ │
│ │ message │
├──────────────────────────────┼───────────────────────┤
│ Extension Contexts │ │
│ ▼ │
│ ┌──────────┐ ┌──────────────────┐ │
│ │ Popup │ │ Service Worker │ │
│ │ Sortable │ │ (coordinator) │ │
│ │ Lists │ └────────┬─────────┘ │
│ └──────────┘ │ │
│ ┌────────▼─────────┐ │
│ │ Side Panel │ │
│ │ Drop Target │ │
│ └──────────────────┘ │
└──────────────────────────────────────────────────────┘
Key constraints:
- Popup windows close on blur — dragging outside the popup will close it
- Content scripts share the page DOM, so drag listeners compete with host page handlers
- Cross-context drags (page to side panel) require message passing — native drag events do not cross boundaries
- File drops need careful
preventDefault()to avoid navigating the page away
Pattern 1: Sortable Lists in Popup UI
Build drag-and-drop reordering for lists in popup or side panel HTML. This pattern tracks the drag source and target using data attributes and swaps elements on drop:
// popup.ts
interface SortableItem {
id: string;
label: string;
order: number;
}
function initSortableList(container: HTMLUListElement): void {
let draggedItem: HTMLLIElement | null = null;
container.addEventListener("dragstart", (e: DragEvent) => {
const target = e.target as HTMLLIElement;
if (!target.matches("[data-sortable-id]")) return;
draggedItem = target;
target.classList.add("dragging");
// Required for Firefox — set some data to enable the drag
e.dataTransfer!.effectAllowed = "move";
e.dataTransfer!.setData("text/plain", target.dataset.sortableId!);
});
container.addEventListener("dragover", (e: DragEvent) => {
e.preventDefault();
e.dataTransfer!.dropEffect = "move";
const target = e.target as HTMLElement;
const overItem = target.closest<HTMLLIElement>("[data-sortable-id]");
if (!overItem || overItem === draggedItem) return;
const rect = overItem.getBoundingClientRect();
const midY = rect.top + rect.height / 2;
if (e.clientY < midY) {
container.insertBefore(draggedItem!, overItem);
} else {
container.insertBefore(draggedItem!, overItem.nextSibling);
}
});
container.addEventListener("dragend", () => {
if (draggedItem) {
draggedItem.classList.remove("dragging");
draggedItem = null;
}
persistOrder(container);
});
}
async function persistOrder(container: HTMLUListElement): Promise<void> {
const items = container.querySelectorAll<HTMLLIElement>("[data-sortable-id]");
const order: Record<string, number> = {};
items.forEach((item, index) => {
order[item.dataset.sortableId!] = index;
});
await chrome.storage.local.set({ sortOrder: order });
}
// Render items
function renderSortableList(
container: HTMLUListElement,
items: SortableItem[]
): void {
container.innerHTML = "";
items
.sort((a, b) => a.order - b.order)
.forEach((item) => {
const li = document.createElement("li");
li.draggable = true;
li.dataset.sortableId = item.id;
li.textContent = item.label;
li.setAttribute("role", "listitem");
container.appendChild(li);
});
initSortableList(container);
}
Minimal CSS for drag feedback:
/* popup.css */
[data-sortable-id] {
padding: 8px 12px;
border: 1px solid #e0e0e0;
border-radius: 4px;
margin: 4px 0;
cursor: grab;
transition: opacity 0.2s, box-shadow 0.2s;
}
[data-sortable-id].dragging {
opacity: 0.4;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
[data-sortable-id]:active {
cursor: grabbing;
}
Pattern 2: File Drop Into Extension Popup
Accept file drops in the popup for processing — image conversion, text extraction, config imports:
// popup.ts
interface FileDropOptions {
accept?: string[]; // MIME types: ["image/png", "application/json"]
maxSizeMB?: number;
onFile: (file: File, content: ArrayBuffer | string) => void;
onError: (message: string) => void;
}
function initFileDrop(
dropZone: HTMLElement,
options: FileDropOptions
): void {
const { accept, maxSizeMB = 10, onFile, onError } = options;
// Prevent default on the entire popup to stop navigation on missed drops
document.addEventListener("dragover", (e) => e.preventDefault());
document.addEventListener("drop", (e) => e.preventDefault());
dropZone.addEventListener("dragenter", (e: DragEvent) => {
e.preventDefault();
// Validate that the drag contains files before showing feedback
const hasFiles = Array.from(e.dataTransfer?.types ?? []).includes("Files");
if (!hasFiles) return;
dropZone.classList.add("drop-active");
});
dropZone.addEventListener("dragleave", (e: DragEvent) => {
// Only remove class when leaving the drop zone, not entering children
const related = e.relatedTarget as Node | null;
if (related && dropZone.contains(related)) return;
dropZone.classList.remove("drop-active");
});
dropZone.addEventListener("drop", async (e: DragEvent) => {
e.preventDefault();
dropZone.classList.remove("drop-active");
const files = Array.from(e.dataTransfer?.files ?? []);
if (files.length === 0) return;
for (const file of files) {
// MIME type validation
if (accept && !accept.some((t) => file.type.match(t))) {
onError(`Unsupported file type: ${file.type || "unknown"}`);
continue;
}
// Size validation
if (file.size > maxSizeMB * 1024 * 1024) {
onError(`File too large: ${file.name} (max ${maxSizeMB}MB)`);
continue;
}
try {
const content = file.type.startsWith("text/")
? await file.text()
: await file.arrayBuffer();
onFile(file, content);
} catch {
onError(`Failed to read file: ${file.name}`);
}
}
});
}
// Usage: import a JSON config
const dropZone = document.getElementById("drop-zone")!;
initFileDrop(dropZone, {
accept: ["application/json"],
maxSizeMB: 5,
onFile: async (file, content) => {
const config = JSON.parse(content as string);
await chrome.storage.local.set({ userConfig: config });
showToast(`Imported ${file.name}`);
},
onError: (msg) => showToast(msg, "error"),
});
Pattern 3: Content Script Drag-and-Drop Overlays
Inject a drop overlay onto web pages that captures dragged content. This pattern creates a floating overlay that appears when the user drags items, and relays the dropped data to the service worker:
// content-script.ts
function createDropOverlay(): HTMLElement {
const overlay = document.createElement("div");
overlay.id = "ext-drop-overlay";
// Shadow DOM isolates styles from the host page
const shadow = overlay.attachShadow({ mode: "closed" });
const style = document.createElement("style");
style.textContent = `
:host {
position: fixed;
top: 0;
right: 0;
width: 300px;
height: 100vh;
z-index: 2147483647;
pointer-events: none;
display: none;
}
:host(.visible) {
display: block;
pointer-events: auto;
}
.drop-target {
width: 100%;
height: 100%;
background: rgba(66, 133, 244, 0.1);
border-left: 3px solid #4285f4;
display: flex;
align-items: center;
justify-content: center;
font-family: system-ui, sans-serif;
font-size: 14px;
color: #4285f4;
}
.drop-target.over {
background: rgba(66, 133, 244, 0.25);
}
`;
const target = document.createElement("div");
target.className = "drop-target";
target.textContent = "Drop here to save";
shadow.appendChild(style);
shadow.appendChild(target);
document.body.appendChild(overlay);
return overlay;
}
function initContentDragListener(): void {
const overlay = createDropOverlay();
const target = overlay.shadowRoot!.querySelector(".drop-target")!;
// Show overlay when a drag enters the page
let dragCounter = 0;
document.addEventListener("dragenter", (e: DragEvent) => {
dragCounter++;
if (dragCounter === 1) {
overlay.classList.add("visible");
}
});
document.addEventListener("dragleave", () => {
dragCounter--;
if (dragCounter === 0) {
overlay.classList.remove("visible");
target.classList.remove("over");
}
});
document.addEventListener("drop", () => {
dragCounter = 0;
overlay.classList.remove("visible");
target.classList.remove("over");
});
// Handle drops on the overlay target
target.addEventListener("dragover", (e: Event) => {
const de = e as DragEvent;
de.preventDefault();
de.stopPropagation();
de.dataTransfer!.dropEffect = "copy";
target.classList.add("over");
});
target.addEventListener("dragleave", () => {
target.classList.remove("over");
});
target.addEventListener("drop", (e: Event) => {
const de = e as DragEvent;
de.preventDefault();
de.stopPropagation();
const data = extractDragData(de);
chrome.runtime.sendMessage({ type: "CONTENT_DROP", payload: data });
});
}
interface DragPayload {
text?: string;
url?: string;
html?: string;
}
function extractDragData(e: DragEvent): DragPayload {
const dt = e.dataTransfer!;
return {
text: dt.getData("text/plain") || undefined,
url: dt.getData("text/uri-list") || undefined,
html: dt.getData("text/html") || undefined,
};
}
initContentDragListener();
Pattern 4: Cross-Context Drag (Page to Side Panel)
Native HTML drag events cannot cross extension context boundaries. This pattern bridges the gap by using chrome.runtime messaging to relay drag data from a content script to the side panel:
// shared/types.ts
interface CrossContextDragMessage {
type: "DRAG_START" | "DRAG_DATA" | "DRAG_END";
payload?: {
text?: string;
url?: string;
sourceTabId?: number;
};
}
// content-script.ts — detect drags and relay via messaging
document.addEventListener("dragstart", (e: DragEvent) => {
const selection = document.getSelection()?.toString();
const link = (e.target as HTMLElement).closest("a");
chrome.runtime.sendMessage({
type: "DRAG_START",
payload: {
text: selection || e.dataTransfer?.getData("text/plain"),
url: link?.href,
},
} satisfies CrossContextDragMessage);
});
document.addEventListener("dragend", () => {
chrome.runtime.sendMessage({
type: "DRAG_END",
} satisfies CrossContextDragMessage);
});
// background.ts — relay to the side panel
chrome.runtime.onMessage.addListener(
(msg: CrossContextDragMessage, sender) => {
if (msg.type === "DRAG_START" || msg.type === "DRAG_END") {
// Forward to all extension views (side panel, popup, etc.)
chrome.runtime
.sendMessage({
...msg,
payload: {
...msg.payload,
sourceTabId: sender.tab?.id,
},
})
.catch(() => {
// Side panel may not be open — ignore
});
}
}
);
// side-panel.ts — receive and display
const dropIndicator = document.getElementById("drop-indicator")!;
const collectedItems = document.getElementById("collected-items")!;
chrome.runtime.onMessage.addListener((msg: CrossContextDragMessage) => {
switch (msg.type) {
case "DRAG_START":
dropIndicator.classList.add("receiving");
dropIndicator.textContent = msg.payload?.text
? `"${msg.payload.text.slice(0, 50)}..."`
: "Incoming item...";
break;
case "DRAG_END":
dropIndicator.classList.remove("receiving");
if (msg.payload?.text || msg.payload?.url) {
addCollectedItem(msg.payload);
}
break;
}
});
function addCollectedItem(
payload: CrossContextDragMessage["payload"]
): void {
const li = document.createElement("li");
if (payload?.url) {
const a = document.createElement("a");
a.href = payload.url;
a.textContent = payload.text || payload.url;
a.target = "_blank";
li.appendChild(a);
} else {
li.textContent = payload?.text ?? "";
}
collectedItems.appendChild(li);
}
Limitation: You cannot detect the actual “drop” moment inside the side panel from a content script drag. The
dragendevent fires when the user releases the mouse, and you relay whatever data was captured atdragstart. For true drop semantics, instruct users to click a “Confirm” button in the side panel after the item appears.
Pattern 5: Custom Drag Previews and Ghost Images
Replace the browser’s default translucent clone with a custom drag image for better visual communication:
// popup.ts
function setCustomDragPreview(
e: DragEvent,
options: {
text: string;
icon?: string; // emoji or text character
bgColor?: string;
width?: number;
}
): void {
const { text, icon, bgColor = "#4285f4", width = 200 } = options;
// Create an offscreen element for the drag image
const preview = document.createElement("div");
preview.style.cssText = `
position: absolute;
top: -1000px;
left: -1000px;
width: ${width}px;
padding: 8px 12px;
background: ${bgColor};
color: white;
border-radius: 6px;
font-family: system-ui, sans-serif;
font-size: 13px;
display: flex;
align-items: center;
gap: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
`;
preview.textContent = icon ? `${icon} ${text}` : text;
document.body.appendChild(preview);
// Position the cursor at the left-center of the preview
e.dataTransfer!.setDragImage(preview, 16, preview.offsetHeight / 2);
// Clean up after the browser captures the snapshot
requestAnimationFrame(() => {
document.body.removeChild(preview);
});
}
// Canvas-based drag image for pixel-perfect rendering
function setCanvasDragPreview(
e: DragEvent,
options: { text: string; count?: number }
): void {
const canvas = document.createElement("canvas");
const dpr = window.devicePixelRatio || 1;
canvas.width = 220 * dpr;
canvas.height = 40 * dpr;
canvas.style.width = "220px";
canvas.style.height = "40px";
const ctx = canvas.getContext("2d")!;
ctx.scale(dpr, dpr);
// Draw rounded rect background
ctx.fillStyle = "#1a73e8";
ctx.beginPath();
ctx.roundRect(0, 0, 220, 40, 8);
ctx.fill();
// Draw text
ctx.fillStyle = "#ffffff";
ctx.font = "600 13px system-ui";
ctx.fillText(options.text, 12, 25);
// Draw count badge
if (options.count && options.count > 1) {
ctx.fillStyle = "#ffffff";
ctx.beginPath();
ctx.arc(200, 20, 12, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "#1a73e8";
ctx.font = "600 11px system-ui";
ctx.textAlign = "center";
ctx.fillText(String(options.count), 200, 24);
}
// Must be in the DOM for setDragImage to work
document.body.appendChild(canvas);
canvas.style.position = "absolute";
canvas.style.top = "-1000px";
e.dataTransfer!.setDragImage(canvas, 16, 20);
requestAnimationFrame(() => document.body.removeChild(canvas));
}
Pattern 6: Drop Zone Visual Feedback and Validation
Provide clear visual indicators for valid, invalid, and active drop states. This pattern validates drag contents before the user drops, using the dataTransfer.types array:
// shared/drop-zone.ts
type DropZoneState = "idle" | "valid" | "invalid" | "over";
interface DropZoneConfig {
element: HTMLElement;
acceptTypes: string[]; // e.g., ["Files", "text/uri-list"]
acceptExtensions?: string[]; // e.g., [".json", ".csv"]
onDrop: (e: DragEvent) => void;
onStateChange?: (state: DropZoneState) => void;
}
function createDropZone(config: DropZoneConfig): () => void {
const { element, acceptTypes, acceptExtensions, onDrop, onStateChange } =
config;
let enterCount = 0;
function setState(state: DropZoneState): void {
element.dataset.dropState = state;
onStateChange?.(state);
}
function isValidDrag(e: DragEvent): boolean {
const types = Array.from(e.dataTransfer?.types ?? []);
return acceptTypes.some((t) => types.includes(t));
}
function handleDragEnter(e: DragEvent): void {
e.preventDefault();
enterCount++;
if (enterCount === 1) {
setState(isValidDrag(e) ? "valid" : "invalid");
}
}
function handleDragOver(e: DragEvent): void {
e.preventDefault();
if (isValidDrag(e)) {
e.dataTransfer!.dropEffect = "copy";
setState("over");
} else {
e.dataTransfer!.dropEffect = "none";
}
}
function handleDragLeave(e: DragEvent): void {
enterCount--;
if (enterCount === 0) {
setState("idle");
}
}
function handleDrop(e: DragEvent): void {
e.preventDefault();
enterCount = 0;
setState("idle");
if (!isValidDrag(e)) return;
// Validate file extensions if specified
if (acceptExtensions && e.dataTransfer?.files.length) {
const files = Array.from(e.dataTransfer.files);
const allValid = files.every((f) =>
acceptExtensions.some((ext) => f.name.toLowerCase().endsWith(ext))
);
if (!allValid) return;
}
onDrop(e);
}
element.addEventListener("dragenter", handleDragEnter);
element.addEventListener("dragover", handleDragOver);
element.addEventListener("dragleave", handleDragLeave);
element.addEventListener("drop", handleDrop);
// Return cleanup function
return () => {
element.removeEventListener("dragenter", handleDragEnter);
element.removeEventListener("dragover", handleDragOver);
element.removeEventListener("dragleave", handleDragLeave);
element.removeEventListener("drop", handleDrop);
};
}
CSS states driven by data-drop-state:
[data-drop-state="idle"] {
border: 2px dashed #ccc;
background: transparent;
}
[data-drop-state="valid"] {
border: 2px dashed #4caf50;
background: rgba(76, 175, 80, 0.05);
}
[data-drop-state="invalid"] {
border: 2px dashed #f44336;
background: rgba(244, 67, 54, 0.05);
cursor: not-allowed;
}
[data-drop-state="over"] {
border: 2px solid #4caf50;
background: rgba(76, 175, 80, 0.12);
transform: scale(1.01);
transition: all 0.15s ease;
}
Pattern 7: Drag Data Types — Text, URLs, Files, and Custom MIME
The DataTransfer API supports multiple data formats simultaneously. Set multiple types on drag so different drop targets can consume the most appropriate format:
// popup.ts — setting multiple data types on a draggable item
interface BookmarkItem {
id: string;
title: string;
url: string;
tags: string[];
}
function initBookmarkDrag(
element: HTMLElement,
bookmark: BookmarkItem
): void {
element.draggable = true;
element.addEventListener("dragstart", (e: DragEvent) => {
const dt = e.dataTransfer!;
dt.effectAllowed = "copyMove";
// Plain text — works everywhere
dt.setData("text/plain", bookmark.url);
// URL — recognized by browsers and OS drop targets
dt.setData("text/uri-list", bookmark.url);
// Rich HTML — pastes nicely into rich text editors
dt.setData(
"text/html",
`<a href="${bookmark.url}">${bookmark.title}</a>`
);
// Custom MIME — only your extension understands this
dt.setData(
"application/x-ext-bookmark",
JSON.stringify(bookmark)
);
});
}
// Reading multiple types on drop
function handleDrop(e: DragEvent): void {
e.preventDefault();
const dt = e.dataTransfer!;
// Try custom type first, fall back to simpler types
const custom = dt.getData("application/x-ext-bookmark");
if (custom) {
const bookmark: BookmarkItem = JSON.parse(custom);
console.log("Full bookmark data:", bookmark);
return;
}
const url = dt.getData("text/uri-list");
if (url) {
console.log("Got URL:", url);
return;
}
const text = dt.getData("text/plain");
if (text) {
console.log("Got plain text:", text);
return;
}
// Handle dropped files
if (dt.files.length > 0) {
for (const file of Array.from(dt.files)) {
console.log("Got file:", file.name, file.type, file.size);
}
}
}
Data type cheat sheet:
| Type | Set via | Read via | Use case |
|---|---|---|---|
text/plain |
setData("text/plain", ...) |
getData("text/plain") |
Universal fallback |
text/uri-list |
setData("text/uri-list", ...) |
getData("text/uri-list") |
URLs, recognized by OS |
text/html |
setData("text/html", ...) |
getData("text/html") |
Rich content with formatting |
Files |
User drags from OS | e.dataTransfer.files |
File uploads |
application/x-* |
setData("application/x-myapp", ...) |
getData("application/x-myapp") |
Custom extension data |
Security note: During
dragover, you can inspectdataTransfer.types(the list of MIME types) but you cannot read the actual data values. Data is only accessible inside thedrophandler. This is a browser security restriction.
Pattern 8: Accessible Drag-and-Drop With Keyboard Alternatives
Drag-and-drop is inherently mouse-centric. Every drag interaction must have a keyboard-accessible alternative for users who rely on assistive technology:
// accessible-sortable.ts
interface AccessibleSortableOptions {
container: HTMLElement;
itemSelector: string;
onReorder: (fromIndex: number, toIndex: number) => void;
}
function initAccessibleSortable(options: AccessibleSortableOptions): void {
const { container, itemSelector, onReorder } = options;
let activeItem: HTMLElement | null = null;
let isReordering = false;
function getItems(): HTMLElement[] {
return Array.from(container.querySelectorAll(itemSelector));
}
function announceToScreenReader(message: string): void {
let announcer = document.getElementById("sr-announcer");
if (!announcer) {
announcer = document.createElement("div");
announcer.id = "sr-announcer";
announcer.setAttribute("role", "status");
announcer.setAttribute("aria-live", "assertive");
announcer.setAttribute("aria-atomic", "true");
announcer.style.cssText = `
position: absolute;
width: 1px; height: 1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
`;
document.body.appendChild(announcer);
}
announcer.textContent = message;
}
container.addEventListener("keydown", (e: KeyboardEvent) => {
const target = (e.target as HTMLElement).closest(
itemSelector
) as HTMLElement | null;
if (!target) return;
const items = getItems();
const index = items.indexOf(target);
// Space or Enter: toggle reorder mode
if (e.key === " " || e.key === "Enter") {
if (!isReordering) {
e.preventDefault();
isReordering = true;
activeItem = target;
target.classList.add("reordering");
target.setAttribute("aria-grabbed", "true");
announceToScreenReader(
`${target.textContent} grabbed. Use arrow keys to move, Space to drop.`
);
} else if (activeItem === target) {
e.preventDefault();
isReordering = false;
target.classList.remove("reordering");
target.setAttribute("aria-grabbed", "false");
announceToScreenReader(`${target.textContent} dropped.`);
activeItem = null;
}
return;
}
// Escape: cancel reorder
if (e.key === "Escape" && isReordering) {
e.preventDefault();
isReordering = false;
activeItem?.classList.remove("reordering");
activeItem?.setAttribute("aria-grabbed", "false");
announceToScreenReader("Reorder cancelled.");
activeItem = null;
return;
}
// Arrow keys: move item when in reorder mode
if (isReordering && activeItem) {
if (e.key === "ArrowUp" && index > 0) {
e.preventDefault();
container.insertBefore(activeItem, items[index - 1]);
activeItem.focus();
onReorder(index, index - 1);
announceToScreenReader(
`Moved to position ${index} of ${items.length}.`
);
} else if (e.key === "ArrowDown" && index < items.length - 1) {
e.preventDefault();
container.insertBefore(activeItem, items[index + 1].nextSibling);
activeItem.focus();
onReorder(index, index + 1);
announceToScreenReader(
`Moved to position ${index + 2} of ${items.length}.`
);
}
}
});
// Set ARIA attributes on items
function setupAria(): void {
const items = getItems();
items.forEach((item, index) => {
item.setAttribute("tabindex", "0");
item.setAttribute("role", "listitem");
item.setAttribute("aria-grabbed", "false");
item.setAttribute(
"aria-label",
`${item.textContent}, position ${index + 1} of ${items.length}. ` +
`Press Space to reorder.`
);
});
container.setAttribute("role", "list");
container.setAttribute(
"aria-label",
"Sortable list. Navigate with Tab, reorder with Space and arrow keys."
);
}
setupAria();
}
Provide visible keyboard instructions alongside the drag-enabled list:
// popup.ts
function renderKeyboardHelp(container: HTMLElement): void {
const help = document.createElement("details");
help.innerHTML = `
<summary>Keyboard shortcuts</summary>
<dl>
<dt><kbd>Tab</kbd></dt>
<dd>Navigate between items</dd>
<dt><kbd>Space</kbd> / <kbd>Enter</kbd></dt>
<dd>Grab or drop the focused item</dd>
<dt><kbd>Arrow Up</kbd> / <kbd>Arrow Down</kbd></dt>
<dd>Move the grabbed item</dd>
<dt><kbd>Escape</kbd></dt>
<dd>Cancel reorder</dd>
</dl>
`;
container.insertAdjacentElement("beforebegin", help);
}
Summary
| Pattern | Context | Key Technique |
|---|---|---|
| Sortable lists | Popup / Side Panel | dragstart + dragover insertion + dragend persist |
| File drop | Popup / Side Panel | Global preventDefault() + MIME and size validation |
| Content script overlay | Content Script | Shadow DOM overlay + message to service worker |
| Cross-context drag | Content Script + Side Panel | dragstart relay via chrome.runtime.sendMessage |
| Custom drag preview | Any UI | setDragImage() with offscreen element or canvas |
| Drop zone feedback | Any UI | data-drop-state attribute + CSS states |
| Multiple data types | Any UI | Set text/plain, text/uri-list, text/html, custom MIME |
| Keyboard accessible | Popup / Side Panel | aria-grabbed + arrow key reorder + live region announcements |
Common Pitfalls
- Popup closes on external drag — You cannot drag items out of a popup. If you need cross-boundary drag, use the side panel instead.
- Missing
preventDefault()— Failing to prevent default ondragovermeansdropwill never fire. Always calle.preventDefault()in yourdragoverhandler. dragenter/dragleavebubbling — These events fire for every child element. Use a counter (dragCounter++/dragCounter--) to track the real enter/leave boundary.- Cannot read data during
dragover—dataTransfer.getData()returns empty strings indragoverfor security. OnlydataTransfer.typesis available. - Firefox requires
setData()— Firefox will not start a drag unless you calle.dataTransfer.setData()with at least one value in thedragstarthandler.
Related Resources
Part of the Chrome Extension Guide by theluckystrike. Built at zovo.one.