Stimulus controller for drag-and-drop file uploads

Jordan Lee Jan 2026
2 tabs
import { Controller } from "@hotwired/stimulus"
import { DirectUpload } from "@rails/activestorage"

export default class extends Controller {
  static targets = ["input", "preview", "status"]
  static values = {
    url: String,
    maxSize: { type: Number, default: 5242880 }, // 5MB
    accept: { type: Array, default: ["image/jpeg", "image/png", "image/gif"] }
  }

  connect() {
    this.element.addEventListener("dragover", (e) => this.dragover(e))
    this.element.addEventListener("dragleave", (e) => this.dragleave(e))
    this.element.addEventListener("drop", (e) => this.drop(e))
  }

  dragover(event) {
    event.preventDefault()
    this.element.classList.add("dragover")
  }

  dragleave(event) {
    event.preventDefault()
    this.element.classList.remove("dragover")
  }

  drop(event) {
    event.preventDefault()
    this.element.classList.remove("dragover")

    const files = Array.from(event.dataTransfer.files)
    this.uploadFiles(files)
  }

  selectFiles() {
    const files = Array.from(this.inputTarget.files)
    this.uploadFiles(files)
  }

  uploadFiles(files) {
    files.forEach(file => {
      if (!this.acceptValue.includes(file.type)) {
        this.showError(`${file.name} has unsupported type`)
        return
      }

      if (file.size > this.maxSizeValue) {
        this.showError(`${file.name} exceeds maximum size`)
        return
      }

      this.uploadFile(file)
    })
  }

  uploadFile(file) {
    const upload = new DirectUpload(file, this.urlValue)

    this.showStatus(`Uploading ${file.name}...`)

    upload.create((error, blob) => {
      if (error) {
        this.showError(`Upload failed: ${error}`)
      } else {
        this.showPreview(file, blob)
        this.showStatus("Upload complete!")
      }
    })
  }

  showPreview(file, blob) {
    if (file.type.startsWith('image/')) {
      const reader = new FileReader()
      reader.onload = (e) => {
        const img = document.createElement('img')
        img.src = e.target.result
        img.className = 'w-24 h-24 object-cover rounded'
        this.previewTarget.appendChild(img)
      }
      reader.readAsDataURL(file)
    }
  }

  showStatus(message) {
    this.statusTarget.textContent = message
    this.statusTarget.className = "text-sm text-blue-600"
  }

  showError(message) {
    this.statusTarget.textContent = message
    this.statusTarget.className = "text-sm text-red-600"
  }
}
2 files · javascript, erb Explain with highlit

Modern file uploads should support drag-and-drop in addition to traditional file inputs. I use Stimulus to handle dragover, drop, and paste events, showing upload previews and progress. The controller prevents default browser behavior for drag events and extracts files from the DataTransfer object. For images, I generate preview thumbnails using FileReader API before upload. The actual upload happens via AJAX to a dedicated endpoint that returns Turbo Stream updates. This pattern works for profile pictures, attachments, or bulk file uploads. I also handle paste events to support clipboard uploads and provide clear error messages for unsupported file types or oversized files.