ComponentsThemes
Palette
Default
Stone
Rose
Blue
Green
Violet
Yellow
Orange
6

← Components

File Upload

Drag-and-drop file upload with a dropzone, browse button, file list with remove, and image previews. Submits via a real multipart file input.

Installation

bin/rails g wabi:add file_upload
bin/importmap pin @zag-js/file-upload @zag-js/vanilla

Pin @zag-js/file-upload and @zag-js/vanilla at 1.41+ using the +esm jsdelivr URLs — bin/importmap pin only fetches the main entry and leaves submodules unresolved.

Example

Multiple / images

Pass max_files: to allow multiple selections (the field name gains [] automatically) and accept: to restrict by MIME type or extension.

Source

app/components/ui/file_upload.rb

# frozen_string_literal: true

require "date"

module Components
  module UI
    class FileUpload < Wabi::Base
      def initialize(name: nil, accept: nil, max_files: 1, max_size: nil, disabled: false, **attrs)
        @name      = name
        @accept    = accept
        @max_files = max_files
        @max_size  = max_size
        @disabled  = disabled
        @attrs     = attrs
      end

      def view_template(&block)
        user_class = @attrs.delete(:class)
        user_data  = @attrs.delete(:data) || {}
        multiple   = @max_files > 1
        field_name = @name && multiple && !@name.end_with?("[]") ? "#{@name}[]" : @name
        root_data = {
          controller: "wabi--file-upload",
          "wabi--file-upload-name-value":      field_name,
          "wabi--file-upload-accept-value":    @accept.to_s,
          "wabi--file-upload-max-files-value": @max_files.to_s,
          "wabi--file-upload-max-size-value":  @max_size.to_s,
          "wabi--file-upload-disabled-value":  @disabled.to_s,
        }
        div(**@attrs, data: user_data.merge(root_data),
            class: merge_class("flex flex-col gap-3", user_class)) do
          input(type: "file", name: field_name, multiple: (multiple || nil),
                data: { "wabi--file-upload-target": "hiddenInput" }, class: "sr-only")
          yield if block
        end
      end
    end
  end
end

app/components/ui/file_upload_dropzone.rb

# frozen_string_literal: true

require "date"

module Components
  module UI
    class FileUploadDropzone < Wabi::Base
      def initialize(**attrs) = @attrs = attrs

      def view_template(&block)
        user_class = @attrs.delete(:class)
        div(**@attrs, data: { "wabi--file-upload-target": "dropzone" },
            class: merge_class(
              "flex flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed " \
              "border-input bg-background px-6 py-10 text-center text-sm text-muted-foreground " \
              "transition-colors motion-reduce:transition-none cursor-pointer data-[dragging]:border-ring data-[dragging]:bg-accent",
              user_class)) do
          yield if block
        end
      end
    end
  end
end

app/components/ui/file_upload_trigger.rb

# frozen_string_literal: true

require "date"

module Components
  module UI
    class FileUploadTrigger < Wabi::Base
      def initialize(**attrs) = @attrs = attrs

      def view_template(&block)
        user_class = @attrs.delete(:class)
        button(type: "button", **@attrs, data: { "wabi--file-upload-target": "trigger" },
               class: merge_class(
                 "inline-flex h-9 items-center justify-center rounded-md border border-input bg-background " \
                 "px-4 text-sm font-medium shadow-sm transition-colors motion-reduce:transition-none hover:bg-accent hover:text-accent-foreground " \
                 "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
                 user_class)) do
          yield if block
        end
      end
    end
  end
end

app/components/ui/file_upload_list.rb

# frozen_string_literal: true

require "date"

module Components
  module UI
    class FileUploadList < Wabi::Base
      def initialize(**attrs) = @attrs = attrs

      def view_template
        user_class = @attrs.delete(:class)
        ul(**@attrs, data: { "wabi--file-upload-target": "list" },
           aria: { live: "polite", atomic: "false" },
           class: merge_class("flex flex-col gap-2 empty:hidden", user_class))
      end
    end
  end
end

app/javascript/controllers/wabi/file_upload_controller.js

import { Controller } from "@hotwired/stimulus"
import * as fileUpload from "@zag-js/file-upload"
import { VanillaMachine, normalizeProps, spreadProps } from "@zag-js/vanilla"

function humanSize(bytes) {
  if (bytes == null) return ""
  const u = ["B", "KB", "MB", "GB"]; let i = 0; let n = bytes
  while (n >= 1024 && i < u.length - 1) { n /= 1024; i++ }
  return `${n.toFixed(n < 10 && i > 0 ? 1 : 0)} ${u[i]}`
}

export default class extends Controller {
  static targets = ["dropzone", "trigger", "hiddenInput", "list"]
  static values = {
    name:     String,
    accept:   String,
    maxFiles: { type: Number, default: 1 },
    maxSize:  String,
    disabled: { type: Boolean, default: false },
  }

  connect() {
    this.machine = new VanillaMachine(fileUpload.machine, {
      id: this.element.id || crypto.randomUUID(),
      name: this.nameValue || undefined,
      accept: this.acceptValue || undefined,
      maxFiles: this.maxFilesValue,
      maxFileSize: this.maxSizeValue ? Number(this.maxSizeValue) : undefined,
      disabled: this.disabledValue,
      onFileChange: () => { this.renderList(); this.dispatch("change") },
    })
    this.unsubscribe = this.machine.subscribe(() => this.render())
    this.machine.start()
    this.render()
    this.renderList()
  }

  disconnect() {
    ;(this._objectUrls || []).forEach((u) => URL.revokeObjectURL(u))
    this.unsubscribe?.()
    this.machine?.stop()
  }

  get api() {
    return fileUpload.connect(this.machine.service, normalizeProps)
  }

  render() {
    const api = this.api
    spreadProps(this.element, api.getRootProps())
    if (this.hasDropzoneTarget)    spreadProps(this.dropzoneTarget,    api.getDropzoneProps())
    if (this.hasTriggerTarget)     spreadProps(this.triggerTarget,     api.getTriggerProps())
    if (this.hasHiddenInputTarget) spreadProps(this.hiddenInputTarget, api.getHiddenInputProps())
  }

  renderList() {
    if (!this.hasListTarget) return
    ;(this._objectUrls ||= []).forEach((u) => URL.revokeObjectURL(u))
    this._objectUrls = []
    const api = this.api
    this.listTarget.innerHTML = ""
    api.acceptedFiles.forEach((file) => {
      const li = document.createElement("li")
      li.className = "flex items-center gap-3 rounded-md border border-input p-2 text-sm"

      // Image preview: only for image files  Zag's getItemPreviewImageProps
      // throws for non-image types, so we guard with a type check.
      if (file.type?.startsWith("image/")) {
        const img = document.createElement("img")
        // getItemPreviewImageProps requires a `url` arg (the object URL).
        // We build it manually to avoid async createFileUrl and to keep
        // the preview synchronous for jsdom compatibility.
        const url = URL.createObjectURL(file)
        this._objectUrls.push(url)
        spreadProps(img, api.getItemPreviewImageProps({ file, url }))
        img.className = "h-10 w-10 rounded object-cover"
        li.appendChild(img)
      }

      const meta = document.createElement("div")
      meta.className = "flex-1 min-w-0"
      const nm = document.createElement("p")
      nm.className = "truncate font-medium"
      nm.textContent = file.name
      const sz = document.createElement("p")
      sz.className = "text-xs text-muted-foreground"
      sz.textContent = humanSize(file.size)
      meta.append(nm, sz)
      li.appendChild(meta)

      const del = document.createElement("button")
      del.type = "button"
      spreadProps(del, api.getItemDeleteTriggerProps({ file }))
      del.className = "shrink-0 rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
      del.setAttribute("aria-label", `Remove ${file.name}`)
      del.textContent = "✕"
      li.appendChild(del)

      this.listTarget.appendChild(li)
    })
  }
}

Accessibility

  • The dropzone has role="button" with tabindex=0 and opens the file picker on Enter or Space.
  • Drag state is reflected via data-[dragging] on the dropzone for visual feedback.
  • The underlying <input type="file"> is tabindex=-1 and aria-hidden (out of the tab order and the accessibility tree); the dropzone and the Browse button are the accessible surfaces.
  • File list renders accepted files; each entry can expose a remove button wired to the controller.
  • max_files > 1 automatically appends [] to the field name and enables the multiple attribute.
  • The dropzone is keyboard-focusable and contains the browse button (Zag's file-upload structure); automated tools may flag this as nested-interactive, but both the dropzone (drag/drop + Enter/Space) and the button are independently operable.