Chrome Extension Drag And Drop — Best Practices

36 min read

Drag 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:


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 dragend event fires when the user releases the mouse, and you relay whatever data was captured at dragstart. 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 inspect dataTransfer.types (the list of MIME types) but you cannot read the actual data values. Data is only accessible inside the drop handler. 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

  1. Popup closes on external drag — You cannot drag items out of a popup. If you need cross-boundary drag, use the side panel instead.
  2. Missing preventDefault() — Failing to prevent default on dragover means drop will never fire. Always call e.preventDefault() in your dragover handler.
  3. dragenter/dragleave bubbling — These events fire for every child element. Use a counter (dragCounter++/dragCounter--) to track the real enter/leave boundary.
  4. Cannot read data during dragoverdataTransfer.getData() returns empty strings in dragover for security. Only dataTransfer.types is available.
  5. Firefox requires setData() — Firefox will not start a drag unless you call e.dataTransfer.setData() with at least one value in the dragstart handler.

Part of the Chrome Extension Guide by theluckystrike. Built at zovo.one.

No previous article
No next article