Fixing dragleave Flicker on Drop Zones

Ignore dragleave events whose relatedTarget is still inside the drop zone (zone.contains(e.relatedTarget)), fall back to an enter/leave counter where relatedTarget is null, always call preventDefault() in dragover, and clear the highlight unconditionally on drop, on dragend and when the drag leaves the window — so the highlight turns on once when a file enters and off once when it leaves.

The bug looks trivial and survives in production for years: the drop zone lights up when a file is dragged over it, then flickers off and on as the pointer crosses the icon, the “Drop files here” text or any other child element, and sometimes stays lit after the file is dropped elsewhere. The cause is that drag events fire per element, not per region: moving from the zone onto its own child fires dragleave on the zone. This page belongs to drag-and-drop file uploads in upload fundamentals and browser APIs. Handling what is actually dropped is covered in handling dropped folders with the DataTransfer API.

When to use this approach

  • Your drop zone has child elements — an icon, a label, a button, a list of already-selected files.
  • The highlight flickers, or stays on after a drag is cancelled or dropped outside the zone.
  • You also want a page-wide overlay that appears when a file is dragged anywhere over the window.

Prerequisites

  1. A drop zone element and CSS for a highlighted state (a class or data- attribute).
  2. Evergreen browsers — relatedTarget on drag events is supported in Chromium and Firefox, but historically null in Safari for some transitions, which is why the counter fallback exists.
  3. Keyboard and click alternatives for the same action; drag-and-drop is never the only way to add files.

Why the highlight flickers

Event sequence as the pointer crosses a child element As a dragged file moves from the zone onto the icon inside it, the browser fires dragenter on the icon and then dragleave on the zone, even though the pointer never left the zone. A naive handler removes the highlight on that dragleave, then the dragenter bubbles and adds it again, producing a flicker. Entering a child fires dragleave on the parent drop zone icon + label child elements pointer moves zone → icon events fired, in order 1. dragenter target=icon (bubbles to zone) 2. dragleave target=zone, relatedTarget=icon naive handler: enter → add class, leave → remove class result: off, on, off, on as you move fix: ignore leave when zone contains relatedTarget The pointer never left the zone — only the event target changed.
Drag events describe element boundaries; the highlight should describe the zone's boundary.

Implementation

export interface DropZoneOptions {
  onFiles: (files: File[], items: DataTransferItemList | null) => void;
  activeClass?: string;
  acceptsFiles?: (dt: DataTransfer) => boolean;
}

export function makeDropZone(zone: HTMLElement, opts: DropZoneOptions): () => void {
  const active = opts.activeClass ?? "is-dragover";
  let depth = 0;                                          // fallback when relatedTarget is null

  const hasFiles = (dt: DataTransfer | null) =>
    !!dt && Array.from(dt.types).includes("Files") && (opts.acceptsFiles?.(dt) ?? true);

  const setActive = (on: boolean) => {
    zone.classList.toggle(active, on);
    zone.setAttribute("aria-dropeffect", on ? "copy" : "none");
  };

  const onEnter = (e: DragEvent) => {
    if (!hasFiles(e.dataTransfer)) return;               // ignore text/link drags
    e.preventDefault();
    depth++;
    setActive(true);
  };

  const onOver = (e: DragEvent) => {
    if (!hasFiles(e.dataTransfer)) return;
    e.preventDefault();                                  // required, or drop never fires
    e.dataTransfer!.dropEffect = "copy";
  };

  const onLeave = (e: DragEvent) => {
    if (!hasFiles(e.dataTransfer)) return;
    const to = e.relatedTarget as Node | null;
    if (to && zone.contains(to)) return;                 // moved onto a child: still inside
    if (to === null) {                                   // browsers without relatedTarget
      depth = Math.max(0, depth - 1);
      if (depth > 0) return;
    }
    depth = 0;
    setActive(false);
  };

  const onDrop = (e: DragEvent) => {
    if (!hasFiles(e.dataTransfer)) return;
    e.preventDefault();                                  // stop the browser opening the file
    depth = 0;
    setActive(false);
    opts.onFiles(Array.from(e.dataTransfer!.files), e.dataTransfer!.items);
  };

  const reset = () => { depth = 0; setActive(false); };

  // If the drag is dropped outside the zone, or leaves the window, clear the state.
  const onWindowDrop = (e: DragEvent) => { if (!zone.contains(e.target as Node)) { e.preventDefault(); reset(); } };
  const onWindowLeave = (e: DragEvent) => { if (e.relatedTarget === null && e.clientX === 0 && e.clientY === 0) reset(); };

  zone.addEventListener("dragenter", onEnter);
  zone.addEventListener("dragover", onOver);
  zone.addEventListener("dragleave", onLeave);
  zone.addEventListener("drop", onDrop);
  window.addEventListener("dragend", reset);
  window.addEventListener("drop", onWindowDrop);
  document.addEventListener("dragleave", onWindowLeave);

  return () => {
    zone.removeEventListener("dragenter", onEnter);
    zone.removeEventListener("dragover", onOver);
    zone.removeEventListener("dragleave", onLeave);
    zone.removeEventListener("drop", onDrop);
    window.removeEventListener("dragend", reset);
    window.removeEventListener("drop", onWindowDrop);
    document.removeEventListener("dragleave", onWindowLeave);
  };
}

// Usage
const zone = document.querySelector<HTMLElement>("#dropzone")!;
makeDropZone(zone, { onFiles: (files) => console.log("dropped", files.map((f) => f.name)) });

A small CSS addition removes the most common cause of flicker before any JavaScript runs:

/* Children never become drag targets, so the zone only sees its own boundary. */
.dropzone.is-dragover * { pointer-events: none; }
.dropzone.is-dragover { outline: 3px dashed #c97c1a; outline-offset: -6px; background: #fdf8f2; }

Line-by-line on the decisions that matter

  • zone.contains(e.relatedTarget). relatedTarget on dragleave is the element the pointer moved onto. If that is inside the zone, the pointer has not left the region, so ignore the event. This alone fixes flicker in Chromium and Firefox.
  • The depth counter fallback. Where relatedTarget is null, count dragenter (which fires on every element entered, bubbling to the zone) against dragleave and only deactivate at zero. Counting alone is fragile — a missed event leaves the zone stuck on — which is why it is the fallback, not the primary rule.
  • pointer-events: none on children while active. With children not hit-testable, the pointer is always “on the zone” from the browser’s point of view, and no child enter/leave events fire. Apply it only in the active state, so buttons inside the zone still work normally.
  • preventDefault() in dragover. Without it the browser treats the zone as not accepting drops, shows a “not allowed” cursor, and drop never fires.
  • Checking types.includes("Files"). Dragging a text selection or a link over the zone should not light it up. dataTransfer.types is readable during the drag even though the files themselves are not.
  • Window-level resets. A drag cancelled with Escape or dropped outside the zone fires dragend on the source (for in-page drags) or nothing on the zone at all (for files from the desktop). Resetting on window drop and on leaving the window keeps the highlight from sticking.

A page-wide overlay without flicker

Many apps show a full-window “Drop to upload” overlay as soon as a file enters the window. The same rules apply, with the window as the zone:

Window overlay lifecycle A file dragged into the window shows the overlay on the first dragenter carrying files. The overlay itself becomes the drop target and uses pointer-events so children never trigger leave events. It hides on drop, on leaving the window where relatedTarget is null, or on dragend. Show on first enter, hide on one of three exits hidden page as usual overlay shown is the drop target enter + Files drop → upload leave window dragend / Escape every exit returns to hidden — no path leaves it stuck on
Once shown, the overlay itself receives the drag, which makes the flicker problem disappear by construction.

Listen for dragenter on window, show the overlay (a fixed, full-viewport element with no interactive children), and attach the drop-zone handlers to the overlay. Because the overlay covers everything and has no hit-testable children, the pointer never crosses an element boundary until it leaves the window.

Configuration gotchas

The browser opens the dropped image in the tab. A drop landed somewhere without preventDefault() — outside the zone, or on the zone before its dragover handler prevented the default. The window-level drop handler above prevents it everywhere; keep it.

The highlight never turns off in Safari after cancelling a drag. Safari may not deliver a final dragleave when a desktop drag is cancelled with Escape. The document dragleave with relatedTarget === null and zero coordinates catches the drag leaving the window; a short timeout that clears the state if no dragover arrives for 150 ms is a belt-and-braces addition.

Nested drop zones both highlight. An inner zone’s events bubble to the outer one. Call e.stopPropagation() in the inner zone’s handlers, or check e.target.closest(".dropzone") === zone before acting.

Screen readers announce nothing. Drag highlights are visual. Pair every drop zone with a real <input type="file"> or a button that opens it, and announce accepted files in a status region.

Accessibility and alternatives

Drag and drop is a pointer-only interaction. Every drop zone must also be a click target that opens a file picker, reachable by keyboard with a visible focus style and a label that says what it accepts (“Add photos — JPEG or PNG, up to 20 MB”). On touch devices, dragging files from outside the browser is limited or impossible, so the picker is the primary path there, not the fallback. The broader patterns — labelling, announcing selections and errors, focus management — are in building an accessible file dropzone.

Keep the visual feedback meaningful as well as stable. A highlight that appears when files enter and disappears when they leave tells users the zone will accept the drop; adding a count (“3 files”) or a warning colour when dataTransfer.items shows a disallowed type during the drag goes further. Item types are readable during dragover even though file contents are not, so you can warn before the drop — the checks that must still run after the drop are in validating dropped files before upload.

Before and after

Highlight state over time while dragging across the zone With the naive handler, the highlight toggles off and on four times as the pointer crosses two child elements. With the fixed handler, it turns on once on entry and off once on exit. Highlight on/off while crossing two children naive fixed enter zone leave zone Each dip in the top trace is a child boundary, not a zone boundary.
After the fix the highlight tracks the zone, not its children.

Verification

Test with a real file dragged from the desktop, in Chrome, Firefox and Safari:

  1. Drag slowly across every child element: the highlight must not change.
  2. Drag in and out repeatedly: it turns on and off exactly once per crossing.
  3. Drag over the zone, then press Escape: the highlight clears.
  4. Drop the file outside the zone: the browser does not navigate to it and the highlight is off.

A quick automated check in Playwright dispatches synthetic drag events with relatedTarget set to a child and asserts the class remains present.

Frequently Asked Questions

Is pointer-events: none on children enough by itself?

Often, yes, and it is the simplest fix. But children added dynamically during the drag, or elements positioned over the zone from outside it, can still produce boundary events. Keep the relatedTarget check as well.

Why not use dragover to set the highlight and a timeout to clear it?

It works — dragover fires continuously while over the zone — and is a common library approach. It costs a timer and a slight delay on exit. The event-based approach is exact; the timeout approach is forgiving. Either is fine if it resets on drop and dragend.

Does the same apply to touch devices?

Mobile browsers mostly do not support dragging files from outside the browser, so drop zones matter little there. Always offer the file picker as the primary action on touch devices.