🔥 Oxide UI v0.1.0 — Dark mode only, copy-paste ready. Get started →
Skip to content

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 with class="sr-only" — never display:none, which breaks keyboard access
    • The drop zone <div> should have role="button", tabindex="0", and keyboard @keydown.enter / @keydown.space to 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-label like "Remove thumbnail.jpg"

    Released under the MIT License.