skip to content

A Vue 3 modal closes through @click.self on its backdrop, but it also closes when a user drag-selects text in the dialog and releases over the backdrop — why, and how do you fix it?

level: seniorimportance: should knowfreq 34%

answer

  1. press and release on different elements
  2. where the browser dispatches that click
  3. self only sees the final target
  4. remember where the press began

basics

~20 s

When a press starts in the dialog and ends on the backdrop, browsers dispatch click to the nearest common ancestor — the backdrop — so .self passes. Record whether mousedown began on the backdrop and close only if it did.

solid answer

~40 s

`.self` only compares `event.target` with `event.currentTarget` for the `click` it is given. When a user presses inside the dialog, drags to select text, and releases over the backdrop, the press and release land on different elements, and the UI Events rules dispatch the `click` to their **nearest common ancestor** — here the backdrop itself. So `target === currentTarget`, `.self` lets it through, and the modal closes with the user's selection. The fix is to remember where the press started: set a flag on `mousedown` (or `pointerdown`) when `target === currentTarget`, and on `@click.self` close only if that flag is set. Keep `.self` for plain clicks from children, and add Escape handling so the modal does not depend on the backdrop alone.

go deeper

for a junior

Know that @click.self on a backdrop ignores clicks bubbling from inside the dialog.

for a middle

Explain that .self compares target and currentTarget, and that a click's target depends on both press and release.

for a senior

Diagnose the drag-release close, fix it by tracking where the press began, and keep Escape and focus handling alongside.

for a principal

Put this behaviour in the shared modal component with a regression test, so every team's dialog gets it right once.

## How .self decides In Vue 3, `.self` is a runtime guard: before your handler runs, it checks `event.target !== event.currentTarget` and returns early if they differ. `currentTarget` is the element the listener is on (the backdrop); `target` is the element the event was dispatched to. For an ordinary click inside the dialog, `target` is the inner element and the event reaches the backdrop only by **bubbling**, so `.self` rejects it. That is exactly why `@click.self="close"` is the standard backdrop pattern. ## Why a drag gets through A `click` is not a primitive input event; the browser synthesises it from a `mousedown` and a `mouseup` with the same button. When those two land on **different** elements, the UI Events specification — and current browsers — dispatch the `click` to the **nearest common inclusive ancestor** of the two targets. Walk through the failing case: 1. The user presses inside the dialog on a paragraph (`mousedown` target: the `<p>`). 2. They drag to select text and overshoot the dialog's edge. 3. They release over the dimmed area (`mouseup` target: the backdrop). 4. The nearest common ancestor of the `<p>` and the backdrop is the backdrop itself, so the `click` is dispatched **to the backdrop**. 5. Now `target === currentTarget`, `.self` passes, and `close()` runs. From Vue's point of view nothing is wrong: the event genuinely targets the backdrop. The bug is in the assumption that "a click on the backdrop" means "the user meant to click the backdrop". The same thing happens with a slider or a resize handle inside the dialog, and on touch devices with pointer interactions that start inside and end outside. ## The fix: track where the press began Only close when **both** the press and the click belong to the backdrop: ```vue <script setup lang="ts"> import { ref } from 'vue' const open = ref(true) let pressedOnBackdrop = false function onPointerDown(e: PointerEvent) { pressedOnBackdrop = e.target === e.currentTarget } function onBackdropClick() { if (pressedOnBackdrop) open.value = false pressedOnBackdrop = false } </script> <template> <div v-if="open" class="backdrop" @pointerdown="onPointerDown" @click.self="onBackdropClick" @keydown.esc="open = false" > <div class="dialog" role="dialog" aria-modal="true" tabindex="-1">…</div> </div> </template> ``` What each part does: - `@pointerdown` records whether the press started on the backdrop itself. It deliberately has **no** `.self`, so a press inside the dialog resets the flag to `false`. - `@click.self` still filters out ordinary bubbling clicks from the dialog. - The handler closes only when both conditions hold, then resets the flag. - `@keydown.esc` gives keyboard users a way out that does not depend on the pointer at all (the dialog needs focus for the key event to reach it). ## Options compared | Approach | Plain click inside dialog | Drag from dialog, release on backdrop | Click on backdrop | |---|---|---|---| | `@click="close"` | closes (bug) | closes (bug) | closes | | `@click.self="close"` | stays open | **closes (bug)** | closes | | `.self` + press tracking | stays open | stays open | closes | | `@mousedown.self="close"` | stays open | stays open | closes on press, before release | Closing on `mousedown.self` also avoids the drag bug, but it closes before the user releases, which feels abrupt and breaks the usual "cancel by dragging away" affordance of a click. ## How to confirm the diagnosis - Reproduce by selecting text and releasing outside; a plain click inside should not close. - Log `event.target` in the backdrop handler: for the failing case it is the backdrop, not an inner element. - Log the `mousedown` target: it is inside the dialog, which proves the press and click disagree.

  • Why not replace @click.self with @click.stop on the Vue 3 dialog element?
    Stopping propagation on the dialog does prevent bubbling clicks from reaching the backdrop, but it does nothing for the drag case, where the click is dispatched to the backdrop directly. It also hides every click inside the dialog from document-level listeners such as outside-click handlers of dropdowns.
  • Would @mousedown.self="close" on the Vue 3 backdrop fix the drag-select bug?
    It avoids it, because a press that starts inside the dialog never targets the backdrop. But the modal closes the instant the button goes down, before release, which removes the usual chance to cancel by dragging away and can let the release land on whatever sits behind the modal.

saying these in an interview costs you the question

  • .self guarantees the whole gesture happened on the element.
  • A click is always dispatched to the element where the mouse was released.
  • The fix is to add .stop to every element inside the dialog.
  • Vue's .self compares the element with the event's composed path.
  • Closing on mousedown.self has no user-visible downside.