File Upload
<input type="file"> + drag events
A styled drop zone over a native <input type="file">. Handles drag-over highlighting, multiple files, simulated upload progress, and removal.
Overview
Preview
Click to upload or drag and drop
PNG, JPG, PDF up to 10MB
Drop zone
html
<div
@dragover.prevent="dragging = true"
@dragleave.prevent="dragging = false"
@drop.prevent="onDrop"
@click="fileInput.click()"
class="flex cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed
px-6 py-10 text-center transition-all"
:class="dragging
? 'border-[#D40C37] bg-[#D40C37]/5'
: 'border-zinc-700 hover:border-zinc-500 hover:bg-zinc-900/40'"
>
<!-- Upload icon -->
<div class="mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-zinc-800">
<svg class="h-5 w-5 text-zinc-400" ...>↑</svg>
</div>
<p class="text-sm font-medium text-zinc-300">
<span class="text-[#D40C37]">Click to upload</span> or drag and drop
</p>
<p class="mt-1 text-xs text-zinc-600">PNG, JPG, PDF up to 10MB</p>
<!-- Hidden native input -->
<input ref="fileInput" type="file" multiple accept="image/*,.pdf" class="sr-only" @change="onFileChange" />
</div>File list with progress
html
<ul class="space-y-2">
<li v-for="file in files" :key="file.id"
class="flex items-center gap-3 rounded-lg border border-zinc-800 bg-zinc-900/50 px-4 py-3">
<!-- File type icon -->
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-zinc-800 text-lg">
{{ file.name.endsWith('.pdf') ? '📄' : '🖼' }}
</div>
<!-- Name + progress -->
<div class="min-w-0 flex-1">
<div class="flex items-center justify-between gap-2">
<p class="truncate text-xs font-medium text-zinc-200">{{ file.name }}</p>
<span class="shrink-0 text-[10px] text-zinc-600 tabular-nums">{{ file.size }}</span>
</div>
<div class="mt-1.5 h-1 w-full overflow-hidden rounded-full bg-zinc-800">
<div
class="h-full rounded-full transition-all duration-700"
:class="file.progress === 100 ? 'bg-emerald-500' : 'bg-[#D40C37]'"
:style="{ width: file.progress + '%' }"
></div>
</div>
<p class="mt-0.5 text-[10px]" :class="file.progress === 100 ? 'text-emerald-400' : 'text-zinc-600'">
{{ file.progress === 100 ? 'Complete' : `Uploading... ${file.progress}%` }}
</p>
</div>
<!-- Remove -->
<button @click="remove(file.id)"
class="shrink-0 rounded-md p-1 text-zinc-600 hover:bg-zinc-800 hover:text-zinc-300 transition-colors">
✕
</button>
</li>
</ul>Full Vue implementation
vue
<script setup>
import { ref } from 'vue'
const dragging = ref(false)
const fileInput = ref(null)
const files = ref([])
let nextId = 1
function formatSize(bytes) {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / 1048576).toFixed(1) + ' MB'
}
function addFiles(fileList) {
Array.from(fileList).forEach(f => {
const id = nextId++
files.value.push({ id, name: f.name, size: formatSize(f.size), progress: 0 })
// Simulate upload — replace with real fetch/axios upload
const interval = setInterval(() => {
const item = files.value.find(x => x.id === id)
if (!item) { clearInterval(interval); return }
item.progress = Math.min(100, item.progress + Math.floor(Math.random() * 20 + 10))
if (item.progress >= 100) clearInterval(interval)
}, 300)
})
}
function onDrop(e) {
dragging.value = false
addFiles(e.dataTransfer.files)
}
function onFileChange(e) {
addFiles(e.target.files)
e.target.value = '' // reset so same file can be re-selected
}
function remove(id) {
files.value = files.value.filter(f => f.id !== id)
}
</script>Real upload with fetch
vue
<script setup>
async function uploadFile(file) {
const form = new FormData()
form.append('file', file)
const res = await fetch('/api/upload', { method: 'POST', body: form })
if (!res.ok) throw new Error('Upload failed')
return res.json()
}
</script>Accessibility notes
- The
<input type="file">must exist in the DOM withclass="sr-only"— neverdisplay:none, which breaks keyboard access - The drop zone
<div>should haverole="button",tabindex="0", and keyboard@keydown.enter/@keydown.spaceto trigger the file dialog - Announced file names and statuses in a live region help screen reader users track upload progress
- Each remove button needs an
aria-labellike"Remove thumbnail.jpg"