Skip to content
  1. Inputs
  2. Image dropzone

Image dropzone

A dashed zone that takes an image by drop or by click, then shows it with Replace and Remove on hover. The value is the data URL of the file, ready for a preview or an upload.

Square, 512 x 512 px.

Wide, PNG or JPG, 2 MB at most.

import { ImageDropzone } from '@/components/image-dropzone'
import { useState } from 'react'

export default function Example() {
  let [icon, setIcon] = useState<string | null>(null)
  let [logo, setLogo] = useState<string | null>(null)

  return (
    <div className="grid grid-cols-[1fr_1.6fr] gap-3">
      <ImageDropzone value={icon} onChange={setIcon} label="Icon" hint="Square, 512 x 512 px." name="logo_square" />
      <ImageDropzone
        value={logo}
        onChange={setLogo}
        label="Logo"
        hint="Wide, PNG or JPG, 2 MB at most."
        name="logo_full"
        maxSize={2 * 1024 * 1024}
      />
    </div>
  )
}
<script setup lang="ts">
import ImageDropzone from '@/components/image-dropzone.vue'
import { ref } from 'vue'

const icon = ref<string | null>(null)
const logo = ref<string | null>(null)
</script>

<template>
  <div class="grid grid-cols-[1fr_1.6fr] gap-3">
    <ImageDropzone v-model="icon" label="Icon" hint="Square, 512 x 512 px." name="logo_square" />
    <ImageDropzone
      v-model="logo"
      label="Logo"
      hint="Wide, PNG or JPG, 2 MB at most."
      name="logo_full"
      :max-size="2 * 1024 * 1024"
    />
  </div>
</template>

Installation

Copy these files into the folder where you keep your components. They import each other with relative paths, so the folder can live anywhere. The packages they rely on are listed on the Installation page.

'use client'

import { ArrowUpTrayIcon, XMarkIcon } from '@heroicons/react/16/solid'
import clsx from 'clsx'
import type React from 'react'
import { useRef, useState } from 'react'

function formatSize(bytes: number) {
  return bytes >= 1024 * 1024 ? `${Math.round((bytes / 1024 / 1024) * 10) / 10} MB` : `${Math.round(bytes / 1024)} KB`
}

function formatTypes(types: string[]) {
  let names = types.map((type) => type.split('/')[1]!.toUpperCase())
  return names.length > 1 ? `${names.slice(0, -1).join(', ')} or ${names.at(-1)}` : names[0]
}

export function ImageDropzone({
  value,
  onChange,
  label,
  hint,
  name,
  accept = ['image/png', 'image/jpeg'],
  maxSize,
  className,
  ...props
}: {
  value: string | null
  onChange: (value: string | null) => void
  label: string
  hint?: string
  name?: string
  accept?: string[]
  maxSize?: number
} & Omit<React.ComponentPropsWithoutRef<'div'>, 'onChange'>) {
  let [dragging, setDragging] = useState(false)
  let [error, setError] = useState<string | null>(null)
  let depth = useRef(0)

  function readFile(file: File | undefined) {
    if (!file) return
    if (!accept.includes(file.type)) {
      setError(`Use a ${formatTypes(accept)} file.`)
      return
    }
    if (maxSize !== undefined && file.size > maxSize) {
      setError(`Use a file under ${formatSize(maxSize)}.`)
      return
    }
    setError(null)
    let reader = new FileReader()
    reader.onload = () => onChange(reader.result as string)
    reader.readAsDataURL(file)
  }

  function onInput(event: React.ChangeEvent<HTMLInputElement>) {
    readFile(event.target.files?.[0])
    event.target.value = ''
  }

  let lower = label.toLowerCase()

  return (
    <div {...props} className={className}>
      {name && <input type="hidden" name={name} value={value ?? ''} />}
      <div
        className={clsx(
          'group relative flex h-24 items-center justify-center overflow-hidden rounded-xl',
          value
            ? 'bg-zinc-950/2.5 ring-1 ring-zinc-950/10 dark:bg-white/5 dark:ring-white/10'
            : dragging
              ? 'border border-dashed border-accent-500 bg-accent-500/5'
              : 'border border-dashed border-zinc-950/15 hover:border-zinc-950/30 dark:border-white/15 dark:hover:border-white/30'
        )}
        onDragEnter={(event) => {
          event.preventDefault()
          depth.current += 1
          setDragging(true)
        }}
        onDragOver={(event) => event.preventDefault()}
        onDragLeave={() => {
          depth.current -= 1
          if (depth.current === 0) setDragging(false)
        }}
        onDrop={(event) => {
          event.preventDefault()
          depth.current = 0
          setDragging(false)
          readFile(event.dataTransfer.files?.[0])
        }}
      >
        {value ? (
          <>
            <img src={value} alt="" className="max-h-16 max-w-[80%] object-contain" />
            <div className="absolute inset-0 flex items-center justify-center gap-2 bg-zinc-950/50 opacity-0 transition-opacity group-hover:opacity-100 has-focus-visible:opacity-100 pointer-coarse:opacity-100">
              <label className="cursor-pointer rounded-lg bg-white px-2.5 py-1 text-xs/5 font-medium text-zinc-950 shadow-sm">
                Replace
                <input
                  type="file"
                  accept={accept.join(',')}
                  aria-label={`Replace ${lower}`}
                  className="sr-only"
                  onChange={onInput}
                />
              </label>
              <button
                type="button"
                className="cursor-pointer rounded-lg bg-white/20 p-1.5 text-white hover:bg-white/30"
                onClick={() => onChange(null)}
              >
                <XMarkIcon className="size-3.5 shrink-0" />
                <span className="sr-only">Remove {lower}</span>
              </button>
            </div>
          </>
        ) : (
          <label className="absolute inset-0 flex cursor-pointer flex-col items-center justify-center gap-1.5">
            <ArrowUpTrayIcon className="size-5 shrink-0 text-zinc-400 sm:size-4 dark:text-zinc-500" />
            <span className="text-sm/5 font-medium text-zinc-950 sm:text-xs/5 dark:text-white">{label}</span>
            <input
              type="file"
              accept={accept.join(',')}
              aria-label={`Upload ${lower}`}
              className="sr-only"
              onChange={onInput}
            />
          </label>
        )}
      </div>
      {error ? (
        <p role="alert" className="mt-1.5 text-sm/5 text-red-600 sm:text-xs/5 dark:text-red-500">
          {error}
        </p>
      ) : (
        hint && <p className="mt-1.5 text-sm/5 text-zinc-500 sm:text-xs/5 dark:text-zinc-400">{hint}</p>
      )}
    </div>
  )
}
<script setup lang="ts">
import { ArrowUpTrayIcon, XMarkIcon } from '@heroicons/vue/16/solid'
import { computed, ref } from 'vue'

function formatSize(bytes: number) {
  return bytes >= 1024 * 1024 ? `${Math.round((bytes / 1024 / 1024) * 10) / 10} MB` : `${Math.round(bytes / 1024)} KB`
}

function formatTypes(types: string[]) {
  const names = types.map((type) => type.split('/')[1]!.toUpperCase())
  return names.length > 1 ? `${names.slice(0, -1).join(', ')} or ${names.at(-1)}` : names[0]
}

const props = withDefaults(
  defineProps<{
    label: string
    hint?: string
    name?: string
    accept?: string[]
    maxSize?: number
  }>(),
  { hint: undefined, name: undefined, accept: () => ['image/png', 'image/jpeg'], maxSize: undefined }
)

const image = defineModel<string | null>({ required: true })

const dragging = ref(false)
const error = ref<string | null>(null)
let depth = 0

const lower = computed(() => props.label.toLowerCase())

function readFile(file: File | undefined) {
  if (!file) return
  if (!props.accept.includes(file.type)) {
    error.value = `Use a ${formatTypes(props.accept)} file.`
    return
  }
  if (props.maxSize !== undefined && file.size > props.maxSize) {
    error.value = `Use a file under ${formatSize(props.maxSize)}.`
    return
  }
  error.value = null
  const reader = new FileReader()
  reader.onload = () => {
    image.value = reader.result as string
  }
  reader.readAsDataURL(file)
}

function onInput(event: Event) {
  const input = event.target as HTMLInputElement
  readFile(input.files?.[0])
  input.value = ''
}

function onDragEnter() {
  depth += 1
  dragging.value = true
}

function onDragLeave() {
  depth -= 1
  if (depth === 0) dragging.value = false
}

function onDrop(event: DragEvent) {
  depth = 0
  dragging.value = false
  readFile(event.dataTransfer?.files?.[0])
}
</script>

<template>
  <div>
    <input v-if="name" type="hidden" :name="name" :value="image ?? ''" />
    <div
      class="group relative flex h-24 items-center justify-center overflow-hidden rounded-xl"
      :class="
        image
          ? 'bg-zinc-950/2.5 ring-1 ring-zinc-950/10 dark:bg-white/5 dark:ring-white/10'
          : dragging
            ? 'border border-dashed border-accent-500 bg-accent-500/5'
            : 'border border-dashed border-zinc-950/15 hover:border-zinc-950/30 dark:border-white/15 dark:hover:border-white/30'
      "
      @dragenter.prevent="onDragEnter"
      @dragover.prevent
      @dragleave="onDragLeave"
      @drop.prevent="onDrop"
    >
      <template v-if="image">
        <img :src="image" alt="" class="max-h-16 max-w-[80%] object-contain" />
        <div
          class="absolute inset-0 flex items-center justify-center gap-2 bg-zinc-950/50 opacity-0 transition-opacity group-hover:opacity-100 has-focus-visible:opacity-100 pointer-coarse:opacity-100"
        >
          <label class="cursor-pointer rounded-lg bg-white px-2.5 py-1 text-xs/5 font-medium text-zinc-950 shadow-sm">
            Replace
            <input
              type="file"
              :accept="accept.join(',')"
              :aria-label="`Replace ${lower}`"
              class="sr-only"
              @change="onInput"
            />
          </label>
          <button
            type="button"
            class="cursor-pointer rounded-lg bg-white/20 p-1.5 text-white hover:bg-white/30"
            @click="image = null"
          >
            <XMarkIcon class="size-3.5 shrink-0" />
            <span class="sr-only">Remove {{ lower }}</span>
          </button>
        </div>
      </template>
      <label v-else class="absolute inset-0 flex cursor-pointer flex-col items-center justify-center gap-1.5">
        <ArrowUpTrayIcon class="size-5 shrink-0 text-zinc-400 sm:size-4 dark:text-zinc-500" />
        <span class="text-sm/5 font-medium text-zinc-950 sm:text-xs/5 dark:text-white">{{ label }}</span>
        <input
          type="file"
          :accept="accept.join(',')"
          :aria-label="`Upload ${lower}`"
          class="sr-only"
          @change="onInput"
        />
      </label>
    </div>
    <p v-if="error" role="alert" class="mt-1.5 text-sm/5 text-red-600 sm:text-xs/5 dark:text-red-500">{{ error }}</p>
    <p v-else-if="hint" class="mt-1.5 text-sm/5 text-zinc-500 sm:text-xs/5 dark:text-zinc-400">{{ hint }}</p>
  </div>
</template>

Component API

PropDefaultDescription
ImageDropzonerenders a <div> element
value-Data URL of the current image, or `null`.
onChange-Called with the data URL of the file dropped or picked, or `null` on remove.
label-What the image is: `Logo`, `Icon`. Shown in the empty zone and read to assistive tech.
hint-One line under the zone: the expected size or format.
name-Adds a hidden input carrying the data URL, for a plain form post.
accept['image/png', 'image/jpeg']MIME types accepted. Any other file is refused with a message under the zone.
maxSize-Largest file accepted, in bytes. A bigger file is refused with a message.
PropDefaultDescription
ImageDropzonerenders a <div> element
v-model-Data URL of the current image, or `null`.
label-What the image is: `Logo`, `Icon`. Shown in the empty zone and read to assistive tech.
hint-One line under the zone: the expected size or format.
name-Adds a hidden input carrying the data URL, for a plain form post.
accept['image/png', 'image/jpeg']MIME types accepted. Any other file is refused with a message under the zone.
maxSize-Largest file accepted, in bytes. A bigger file is refused with a message.

Examples

Basic example

One zone per image, each with the label of what goes in and a hint on the size. Drop a file or click the zone; once an image is in, hover it to replace or remove. SVG is refused on purpose, it can carry scripts:

Square, 512 x 512 px.

Wide, PNG or JPG, 2 MB at most.

import { ImageDropzone } from '@/components/image-dropzone'
import { useState } from 'react'

export default function Example() {
  let [icon, setIcon] = useState<string | null>(null)
  let [logo, setLogo] = useState<string | null>(null)

  return (
    <div className="grid grid-cols-[1fr_1.6fr] gap-3">
      <ImageDropzone value={icon} onChange={setIcon} label="Icon" hint="Square, 512 x 512 px." name="logo_square" />
      <ImageDropzone
        value={logo}
        onChange={setLogo}
        label="Logo"
        hint="Wide, PNG or JPG, 2 MB at most."
        name="logo_full"
        maxSize={2 * 1024 * 1024}
      />
    </div>
  )
}
<script setup lang="ts">
import ImageDropzone from '@/components/image-dropzone.vue'
import { ref } from 'vue'

const icon = ref<string | null>(null)
const logo = ref<string | null>(null)
</script>

<template>
  <div class="grid grid-cols-[1fr_1.6fr] gap-3">
    <ImageDropzone v-model="icon" label="Icon" hint="Square, 512 x 512 px." name="logo_square" />
    <ImageDropzone
      v-model="logo"
      label="Logo"
      hint="Wide, PNG or JPG, 2 MB at most."
      name="logo_full"
      :max-size="2 * 1024 * 1024"
    />
  </div>
</template>

Accepted types

Narrow accept when the target wants one format. A file of another type is ignored, say so in the hint:

PNG only, 32 x 32 px.

<ImageDropzone value={image} onChange={setImage} label="Favicon" hint="PNG only, 32 x 32 px." accept={['image/png']} />
<ImageDropzone v-model="favicon" label="Favicon" hint="PNG only, 32 x 32 px." :accept="['image/png']" />