<div data-controller="upload-progress">
<%= form.file_field :avatar, direct_upload: true, data: { action: 'change->upload-progress#start' } %>
<div class="mt-2 h-2 w-full rounded bg-gray-200">
<div data-upload-progress-target="bar" class="h-2 w-0 rounded bg-blue-500"></div>
</div>
</div>
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["bar"]
connect() {
this.element.addEventListener("direct-upload:progress", this.onProgress)
}
disconnect() {
this.element.removeEventListener("direct-upload:progress", this.onProgress)
}
start() {
this.barTarget.style.width = "0%"
}
onProgress = (event) => {
const { progress } = event.detail
this.barTarget.style.width = `${progress}%`
}
}
Direct uploads are great because they keep file traffic away from your Rails dynos, but the default UX is opaque. I attach a Stimulus controller that listens for Active Storage’s direct-upload:* events and updates a progress bar. This keeps the markup server-rendered and the behavior reusable across forms. The controller can also disable submit until upload completes, which prevents a confusing “record saved but file missing” state. In a Turbo app, this plays nicely because the form is still submitted normally; the only enhancement is progress feedback. I also keep the progress UI inside a small wrapper so it’s easy to style with Tailwind.