Back to Blogs

Drag-and-Drop File Upload in Vue 3 Without a Library

Why Not Use a Library

The PDF tools on this site all start the same way: you drop a file in, something happens, you download the result. I needed a drag-and-drop upload zone for each one.

I looked at the available libraries. Most of them are several hundred kilobytes and built primarily around React. The Vue-specific ones were either abandoned or thin wrappers around the same HTML5 APIs I could call myself. I skipped all of them.

The HTML5 Drag-and-Drop API is in every modern browser. The behavior I needed — styled drop zone, click-to-browse fallback, file type filtering — came out to about 80 lines of Vue.


The Events You Actually Need

The drag-and-drop API has six events. You only use four:

EventWhen it fires
dragenterA dragged item enters the drop target
dragoverA dragged item hovers over the target (fires repeatedly)
dragleaveA dragged item leaves the drop target
dropThe user releases the item over the target

The other two — dragstart and dragend — fire on the element being dragged. Since you don't control the file, they're not useful here.

Two things to know before writing any code.

First, dragover must call event.preventDefault() or the drop event will never fire. Browsers block drops by default to prevent accidental file opens. You opt in by preventing the dragover.

Second, e.dataTransfer.files returns a FileList, not an array. Call Array.from() on it before doing anything with the result.


The Naive Approach

<template>
  <div
    @dragover.prevent="isDragging = true"
    @dragleave="isDragging = false"
    @drop.prevent="onDrop"
    :class="isDragging ? 'border-blue-500' : 'border-gray-300'"
    class="border-2 border-dashed rounded-xl p-12 cursor-pointer"
  >
    Drop files here
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'

const isDragging = ref(false)

const emit = defineEmits<{ 'files-selected': [files: File[]] }>()

const onDrop = (e: DragEvent) => {
  isDragging.value = false
  const files = Array.from(e.dataTransfer?.files ?? [])
  emit('files-selected', files)
}
</script>

This looks correct. In a test with a bare <div> and no children it works fine — the border goes blue on hover, reverts on drop. Then you add an icon, some label text, an SVG — and the border starts flickering.


The dragenter/dragleave Child-Element Bug

What's happening: dragleave fires on the parent whenever the cursor moves into a child element. The child is now the drag target, not the parent, so from the parent's perspective you've left. Then dragover fires from the child, bubbles up to the parent, and sets isDragging back to true.

The result is a fast flicker that's barely visible with quick mouse movements and clearly broken when dragging slowly. On mobile it's almost always noticeable.

The first fix people reach for is checking event.relatedTarget in the dragleave handler:

const onDragLeave = (e: DragEvent) => {
  const el = e.currentTarget as HTMLElement
  if (el.contains(e.relatedTarget as Node)) return
  isDragging.value = false
}

relatedTarget is where the pointer went after leaving. If it's still inside the drop zone, skip the state reset. This works in Chrome and Safari.

It doesn't work reliably in Firefox when the drag starts from outside the browser window, where relatedTarget comes back null even for moves that stay inside the zone. If you're building something people actually use, Firefox matters — it's still 3–4% of desktop traffic.

The fix that actually works everywhere is a counter:

let dragDepth = 0

const onDragEnter = () => {
  dragDepth++
  isDragging.value = true
}

const onDragLeave = () => {
  dragDepth--
  if (dragDepth === 0) isDragging.value = false
}

const onDrop = (e: DragEvent) => {
  dragDepth = 0
  isDragging.value = false
  // handle files...
}

dragenter fires whenever the pointer enters any element in the zone — the parent or a child. dragleave fires when it leaves one. The counter tracks how deep you are. It goes positive when you enter, negative as you leave children, and hits zero only when you've left the whole zone.

The reset to 0 in onDrop is important. If the user drops on a child element, dragDepth might be 2 or 3. Resetting it prevents the zone from staying in the active state after the drop.

Note that you now need @dragenter on the template — the naive version omitted it because @dragover was handling the visual state.


The Input Reset Trap

The click fallback uses a hidden <input type="file">. There's one more bug to handle.

If a user opens the file picker, selects a file, then later tries to select the same file again — the change event won't fire. The browser sees the value hasn't changed and skips it.

The fix is one line:

const onFileSelect = (e: Event) => {
  const input = e.target as HTMLInputElement
  const files = Array.from(input.files ?? [])
  processFiles(files)
  input.value = ''  // allows re-selecting the same file
}

Clearing the value after every selection means the next pick always triggers change, regardless of what was previously selected.


The Full Component

<template>
  <div
    @dragenter.prevent="onDragEnter"
    @dragover.prevent
    @dragleave.prevent="onDragLeave"
    @drop.prevent="onDrop"
    @click="fileInput?.click()"
    :class="isDragging
      ? 'border-blue-500 bg-blue-50 dark:bg-blue-950/20'
      : 'border-gray-300 dark:border-gray-700 hover:border-gray-400 dark:hover:border-gray-600'"
    class="border-2 border-dashed rounded-xl p-12 text-center cursor-pointer transition-colors"
  >
    <p class="font-medium text-black dark:text-white">
      {{ isDragging ? 'Drop here' : 'Drag & drop files here' }}
    </p>
    <p class="text-sm text-gray-500 mt-1">
      or <span class="text-blue-500">browse</span>
    </p>

    <input
      ref="fileInput"
      type="file"
      :accept="accept"
      :multiple="multiple"
      class="hidden"
      @change="onFileSelect"
    />
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'

const props = withDefaults(defineProps<{
  accept?: string
  multiple?: boolean
  maxSize?: number
}>(), {
  accept: '*',
  multiple: false,
})

const emit = defineEmits<{
  'files-selected': [files: File[]]
}>()

const isDragging = ref(false)
const fileInput = ref<HTMLInputElement | null>(null)
let dragDepth = 0

const onDragEnter = () => {
  dragDepth++
  isDragging.value = true
}

const onDragLeave = () => {
  dragDepth--
  if (dragDepth === 0) isDragging.value = false
}

const onDrop = (e: DragEvent) => {
  dragDepth = 0
  isDragging.value = false
  const files = Array.from(e.dataTransfer?.files ?? [])
  processFiles(files)
}

const onFileSelect = (e: Event) => {
  const input = e.target as HTMLInputElement
  const files = Array.from(input.files ?? [])
  processFiles(files)
  input.value = ''
}

const processFiles = (files: File[]) => {
  let result = props.multiple ? files : [files[0]]
  if (props.maxSize) {
    const maxBytes = props.maxSize * 1024 * 1024
    result = result.filter(f => f.size <= maxBytes)
  }
  if (result.length > 0) emit('files-selected', result)
}
</script>

The component emits files-selected with a plain File[]. The parent decides what to do with them — display thumbnails, start processing, whatever. The upload zone has no opinion about that.


This component runs on every PDF tool on this site — the PDF editor, the merge tool, the image resizer, all of them. The drag-and-drop counter fix is the one part I almost skipped and then went back for after noticing the flicker in testing. Worth the extra eight lines.

Enjoyed this?

Share it with your network