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.
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
| Prop | Default | Description |
|---|---|---|
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. |
| Prop | Default | Description |
|---|---|---|
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:
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']" />