Vue componentFree

File Dropzone

Drag-and-drop file upload zone for Vue 3 with click to browse, type and size validation, image previews and progress bars. Tailwind CSS, dark mode.

by Vueplayv1.0.0Updated 2 hours ago

Install
Full screen ↗

About this component

A drop area for attachments, avatars, imports and asset libraries. Visitors drag files onto it or click (or press Enter/Space) to open the file picker; files are checked against accept, maxSize and maxFiles, and anything rejected is explained in a dismissable alert instead of silently disappearing.

Accepted files appear in a list with a thumbnail for images, the size and a remove button. The component deliberately doesn't upload anything: it emits add, remove and change with the files so you can send them with fetch, a signed URL or your form submit. Pass a progress map of file id to percent and each row shows a progress bar, then an Uploaded state at 100.

The real file input is visually hidden but stays focusable, so keyboard and screen reader users get the native picker. Call addFiles() through a template ref to add files from paste or other sources, and clear() to reset.

  • Drag and drop plus click or keyboard to browse
  • accept, maxSize, maxFiles and multiple validation
  • Image thumbnails and human-readable sizes
  • Optional per-file progress bars
  • Clear rejection messages

Code

11 files in the repo
<script lang="ts">
export interface DropzoneFile {
  /** Stable id for keys and for the `progress` map. */
  id: string
  file: File
  /** Object URL for image previews (revoked automatically on remove). */
  previewUrl?: string
}

export interface DropzoneRejection {
  file: File
  reason: 'type' | 'size' | 'count'
  message: string
}

export function formatBytes(bytes: number): string {
  if (bytes < 1024) return `${bytes} B`
  const units = ['KB', 'MB', 'GB']
  let value = bytes / 1024
  let i = 0
  while (value >= 1024 && i < units.length - 1) { value /= 1024; i++ }
  return `${value < 10 ? value.toFixed(1) : Math.round(value)} ${units[i]}`
}
</script>

<script setup lang="ts">
import { computed, onBeforeUnmount, ref, useId } from 'vue'

const props = withDefaults(defineProps<{
  /** Same syntax as the input's accept attribute, e.g. "image/*,.pdf". Empty accepts anything. */
  accept?: string
  /** Maximum size per file in bytes. 0 disables the check. */
  maxSize?: number
  multiple?: boolean
  /** Maximum number of files in the list. 0 means no limit. */
  maxFiles?: number
  disabled?: boolean
  label?: string
  /** Helper text under the label. Generated from accept/maxSize when omitted. */
  hint?: string
  /** Upload progress (0–100) per file id. Files at 100 show as complete. */
  progress?: Record<string, number>
}>(), {
  accept: 'image/*,.pdf,.zip',
  maxSize: 5 * 1024 * 1024,
  multiple: true,
  maxFiles: 0,
  disabled: false,
  label: 'Drop files here or click to browse',
  hint: undefined,
  progress: () => ({}),
})

const emit = defineEmits<{
  /** Full list after any change. */
  change: [files: DropzoneFile[]]
  add: [added: DropzoneFile[]]
  remove: [removed: DropzoneFile]
  reject: [rejections: DropzoneRejection[]]
}>()

defineSlots<{
  /** Replace the icon and text inside the drop area. */
  default?: (props: { dragging: boolean }) => unknown
  /** Extra content on each file row, e.g. a retry button. */
  'file-actions'?: (props: { item: DropzoneFile }) => unknown
}>()

const inputId = useId()
const hintId = `${inputId}-hint`
const input = ref<HTMLInputElement | null>(null)
const items = ref<DropzoneFile[]>([])
const rejections = ref<DropzoneRejection[]>([])
const dragDepth = ref(0)
const dragging = computed(() => dragDepth.value > 0)

const acceptList = computed(() => props.accept.split(',').map(s => s.trim().toLowerCase()).filter(Boolean))

const autoHint = computed(() => {
  const types = acceptList.value.map(a => a === 'image/*' ? 'Images' : a.startsWith('.') ? a.slice(1).toUpperCase() : a).join(', ')
  const parts = [types && `${types}`, props.maxSize > 0 && `up to ${formatBytes(props.maxSize)}`, props.maxFiles > 0 && `max ${props.maxFiles} files`].filter(Boolean)
  return parts.join(' · ')
})

function accepts(file: File) {
  if (!acceptList.value.length) return true
  const name = file.name.toLowerCase()
  const type = file.type.toLowerCase()
  return acceptList.value.some(rule => rule.startsWith('.')
    ? name.endsWith(rule)
    : rule.endsWith('/*') ? type.startsWith(rule.slice(0, -1)) : type === rule)
}

const fileId = (f: File) => `${f.name}-${f.size}-${f.lastModified}`

/** Validate and add files. Exposed so parents can add files programmatically (e.g. from paste). */
function addFiles(list: FileList | File[]) {
  if (props.disabled) return
  const incoming = Array.from(list)
  const accepted: DropzoneFile[] = []
  const rejected: DropzoneRejection[] = []
  const current = props.multiple ? [...items.value] : []
  const candidates = props.multiple ? incoming : incoming.slice(0, 1)

  for (const file of candidates) {
    if (!accepts(file)) { rejected.push({ file, reason: 'type', message: `${file.name} isn’t an accepted file type.` }); continue }
    if (props.maxSize > 0 && file.size > props.maxSize) { rejected.push({ file, reason: 'size', message: `${file.name} is ${formatBytes(file.size)}; the limit is ${formatBytes(props.maxSize)}.` }); continue }
    const id = fileId(file)
    if (current.some(i => i.id === id) || accepted.some(i => i.id === id)) continue
    if (props.maxFiles > 0 && current.length + accepted.length >= props.maxFiles) { rejected.push({ file, reason: 'count', message: `${file.name} wasn’t added: you can upload up to ${props.maxFiles} files.` }); continue }
    accepted.push({ id, file, previewUrl: file.type.startsWith('image/') ? URL.createObjectURL(file) : undefined })
  }

  rejections.value = rejected
  if (rejected.length) emit('reject', rejected)
  if (!accepted.length) return
  if (!props.multiple) items.value.forEach(revoke)
  items.value = [...current, ...accepted]
  emit('add', accepted)
  emit('change', items.value)
}

function remove(item: DropzoneFile) {
  revoke(item)
  items.value = items.value.filter(i => i.id !== item.id)
  emit('remove', item)
  emit('change', items.value)
}

function clear() {
  items.value.forEach(revoke)
  items.value = []
  rejections.value = []
  emit('change', [])
}

function revoke(item: DropzoneFile) {
  if (item.previewUrl) URL.revokeObjectURL(item.previewUrl)
}
onBeforeUnmount(() => items.value.forEach(revoke))

function onInput(e: Event) {
  const el = e.target as HTMLInputElement
  if (el.files) addFiles(el.files)
  el.value = '' // allow picking the same file again after removing it
}
function onDragEnter(e: DragEvent) {
  if (props.disabled || !e.dataTransfer?.types.includes('Files')) return
  dragDepth.value++
}
function onDragOver(e: DragEvent) {
  if (props.disabled) return
  e.preventDefault()
  if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'
}
function onDrop(e: DragEvent) {
  e.preventDefault()
  dragDepth.value = 0
  if (e.dataTransfer?.files.length) addFiles(e.dataTransfer.files)
}

function extension(name: string) {
  const dot = name.lastIndexOf('.')
  return dot > 0 ? name.slice(dot + 1, dot + 5).toUpperCase() : 'FILE'
}

defineExpose({ addFiles, clear, open: () => input.value?.click(), files: items })
</script>

<template>
  <div class="w-full">
    <label
      :for="inputId"
      class="group relative flex flex-col items-center justify-center rounded-2xl border-2 border-dashed px-6 py-10 text-center transition has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-teal-500 has-[:focus-visible]:ring-offset-2 dark:has-[:focus-visible]:ring-offset-gray-950"
      :class="disabled
        ? 'cursor-not-allowed border-gray-200 bg-gray-50 opacity-60 dark:border-gray-800 dark:bg-gray-900'
        : dragging
          ? 'cursor-copy border-teal-500 bg-teal-50 dark:border-teal-400 dark:bg-teal-500/10'
          : 'cursor-pointer border-gray-300 bg-white hover:border-teal-400 hover:bg-teal-50/40 dark:border-gray-700 dark:bg-gray-900 dark:hover:border-teal-500/60 dark:hover:bg-teal-500/5'"
      @dragenter="onDragEnter"
      @dragleave="dragDepth = Math.max(0, dragDepth - 1)"
      @dragover="onDragOver"
      @drop="onDrop"
    >
      <input
        :id="inputId"
        ref="input"
        type="file"
        class="sr-only"
        :accept="accept || undefined"
        :multiple="multiple"
        :disabled="disabled"
        :aria-describedby="hintId"
        @change="onInput"
      >
      <slot :dragging="dragging">
        <span
          class="grid size-12 place-items-center rounded-xl transition"
          :class="dragging ? 'scale-110 bg-teal-600 text-white' : 'bg-teal-50 text-teal-700 group-hover:scale-105 dark:bg-teal-500/10 dark:text-teal-300'"
          aria-hidden="true"
        >
          <svg viewBox="0 0 24 24" class="size-6 fill-none stroke-current" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
            <path d="M12 16V4m0 0L7.5 8.5M12 4l4.5 4.5" />
            <path d="M4 15v2.5A2.5 2.5 0 0 0 6.5 20h11a2.5 2.5 0 0 0 2.5-2.5V15" />
          </svg>
        </span>
        <span class="mt-4 text-sm font-semibold text-gray-900 dark:text-white">
          {{ dragging ? 'Release to add files' : label }}
        </span>
      </slot>
      <span :id="hintId" class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ hint ?? autoHint }}</span>
    </label>

    <div v-if="rejections.length" role="alert" class="mt-3 rounded-xl border border-rose-200 bg-rose-50 p-3 text-sm text-rose-800 dark:border-rose-500/30 dark:bg-rose-500/10 dark:text-rose-200">
      <div class="flex items-start gap-2">
        <svg viewBox="0 0 20 20" class="mt-0.5 size-4 shrink-0 fill-current" aria-hidden="true"><path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0Zm-8-5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-1.5 0v-4.5A.75.75 0 0 1 10 5Zm0 10a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" clip-rule="evenodd" /></svg>
        <ul class="flex-1 space-y-0.5">
          <li v-for="r in rejections" :key="`${r.file.name}-${r.reason}`">{{ r.message }}</li>
        </ul>
        <button type="button" class="-m-1 rounded-md p-1 hover:bg-rose-100 focus-visible:outline-2 focus-visible:outline-rose-500 dark:hover:bg-rose-500/20" aria-label="Dismiss" @click="rejections = []">
          <svg viewBox="0 0 20 20" class="size-4 fill-current" aria-hidden="true"><path d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z" /></svg>
        </button>
      </div>
    </div>

    <ul v-if="items.length" class="mt-4 space-y-2" aria-label="Selected files">
      <li
        v-for="item in items"
        :key="item.id"
        class="flex items-center gap-3 rounded-xl border border-gray-200 bg-white p-3 dark:border-gray-800 dark:bg-gray-900"
      >
        <img v-if="item.previewUrl" :src="item.previewUrl" alt="" @error="item.previewUrl = undefined" class="size-10 shrink-0 rounded-lg object-cover ring-1 ring-gray-200 dark:ring-gray-700">
        <span v-else class="grid size-10 shrink-0 place-items-center rounded-lg bg-gray-100 text-[10px] font-bold tracking-wide text-gray-600 dark:bg-gray-800 dark:text-gray-300" aria-hidden="true">
          {{ extension(item.file.name) }}
        </span>
        <div class="min-w-0 flex-1">
          <div class="flex items-baseline justify-between gap-3">
            <p class="truncate text-sm font-medium text-gray-900 dark:text-white">{{ item.file.name }}</p>
            <p class="shrink-0 text-xs text-gray-500 tabular-nums dark:text-gray-400">
              <template v-if="progress[item.id] !== undefined && progress[item.id]! < 100">{{ Math.round(progress[item.id]!) }}% · </template>{{ formatBytes(item.file.size) }}
            </p>
          </div>
          <div
            v-if="progress[item.id] !== undefined && progress[item.id]! < 100"
            class="mt-2 h-1.5 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800"
            role="progressbar"
            :aria-valuenow="Math.round(progress[item.id]!)"
            aria-valuemin="0"
            aria-valuemax="100"
            :aria-label="`Uploading ${item.file.name}`"
          >
            <div class="h-full rounded-full bg-teal-500 transition-[width] duration-300" :style="{ width: `${progress[item.id]}%` }" />
          </div>
          <p v-else-if="progress[item.id] === 100" class="mt-0.5 flex items-center gap-1 text-xs font-medium text-teal-700 dark:text-teal-400">
            <svg viewBox="0 0 20 20" class="size-3.5 fill-current" aria-hidden="true"><path fill-rule="evenodd" d="M16.7 5.3a1 1 0 0 1 0 1.4l-8 8a1 1 0 0 1-1.4 0l-4-4a1 1 0 1 1 1.4-1.4L8 12.6l7.3-7.3a1 1 0 0 1 1.4 0Z" clip-rule="evenodd" /></svg>
            Uploaded
          </p>
          <p v-else class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">{{ item.file.type || 'Unknown type' }}</p>
        </div>
        <slot name="file-actions" :item="item" />
        <button
          type="button"
          class="grid size-8 shrink-0 place-items-center rounded-lg text-gray-400 transition hover:bg-gray-100 hover:text-gray-700 focus-visible:outline-2 focus-visible:outline-teal-500 dark:hover:bg-gray-800 dark:hover:text-gray-200"
          :aria-label="`Remove ${item.file.name}`"
          @click="remove(item)"
        >
          <svg viewBox="0 0 20 20" class="size-4 fill-current" aria-hidden="true"><path d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z" /></svg>
        </button>
      </li>
    </ul>
  </div>
</template>

Props

PropTypeDefaultDescription
acceptstring'image/*,.pdf,.zip'Accepted types in input accept syntax: MIME types, wildcards like image/* or extensions like .pdf. Empty string accepts anything.
maxSizenumber5242880 (5 MB)Maximum size per file in bytes. 0 disables the check.
multiplebooleantrueAllow several files. When false, a new file replaces the current one.
maxFilesnumber0Maximum number of files in the list. 0 means no limit.
disabledbooleanfalseDisable dropping and browsing.
labelstring'Drop files here or click to browse'Main text in the drop area.
hintstringundefinedHelper text under the label. Generated from accept, maxSize and maxFiles when omitted.
progressRecord<string, number>{}Upload progress 0–100 keyed by file id. Shows a progress bar per file, and an Uploaded state at 100.

Events

EventPayloadDescription
change(files: DropzoneFile[])The full file list after any add, remove or clear.
add(added: DropzoneFile[])Newly accepted files. Start uploads here.
remove(removed: DropzoneFile)A file was removed from the list.
reject(rejections: DropzoneRejection[])Files that failed validation, with reason ('type' | 'size' | 'count') and a readable message.

Slots

SlotDescription
defaultReplace the icon and label inside the drop area. Receives { dragging }.
file-actionsExtra controls on each file row, before the remove button. Receives { item }.
All forms →
Preview of Contact FormFree

Component · Forms

Contact Form

Accessible Vue 3 contact form with inline validation, a live character counter and loading, success and error states. Tailwind CSS, dark mode.

by Vueplay
Preview of Multi-Step FormFree

Component · Forms

Multi-Step Form

Config-driven Vue 3 multi-step form wizard with a step indicator, per-step validation, back/next, a review step and a submit event. Tailwind CSS.

by Vueplay
Preview of Hero Email CaptureFree

Component · Hero sections

Hero Email Capture

Vue 3 waitlist hero with an accessible email form: inline validation, loading and success states, and a typed submit event. Tailwind CSS, dark mode.

by Vueplay
Preview of Kanban Board$12

Component · Data display

Kanban Board

Vue 3 kanban board with native drag and drop, keyboard moves, WIP limits and inline add card. v-model board state, Tailwind CSS, dark mode.

by Vueplay

Pro feature

Upgrade to Pro

Upgrade to unlock more of Vueplay. Cancel any time.

  • Custom domains with HTTPS
  • Private projects
  • No "Made with Vueplay" badge
  • GitHub sync

$120/year

Compare plansBilling settings