Skip to content

File upload

file-upload is the blessed multipart pattern: selecting a file and uploading shows a live progress bar, appends the new file’s fragment to the list, resets the form, and toasts. htmx owns the transport (data-hx-encoding + htmx:xhr:progress are native); the only glue is installUploadProgress() driving the native <progress>. Stable under the markup versioning policy.

It needs installUploadProgress(), installToast() and installFieldErrors() (all in the auto-init ./behaviors bundle).

Pick a small PDF or PNG and upload it: the progress bar is driven by htmx:xhr:progress (the installUploadProgress() bridge), and the response prepends the new item to the list while swapping in a pristine form out-of-band — the blessed file-input reset. Try a disallowed extension (say a .txt) to see the retargeted 422 field-error path. The second form is the dropzone variant — drop a file onto it (or click) and the same contract plays out against its own error container and out-of-band reset. Uploads aren’t stored: the demo derives the item from the request and forgets it, so reloading resets the list.

Dropzone variant — same endpoint, same contract

    <form id="upload-form" method="post" action="/files"
    enctype="multipart/form-data"
    data-hx-post="/files"
    data-hx-encoding="multipart/form-data"
    data-hx-target="#files" data-hx-swap="afterbegin"
    data-hx-indicator="find progress"
    data-hx-disabled-elt="find button[type=submit]">
    <div id="upload-errors"></div>
    <div class="hc-field">
    <label class="hc-field__label" for="doc">Document</label>
    <input class="hc-input" id="doc" name="doc" type="file" required
    accept=".pdf,.png">
    </div>
    <progress class="hc-progress htmx-indicator" data-hc-upload-progress
    value="0" max="100" aria-label="Upload progress"></progress>
    <button class="hc-button" data-variant="primary" type="submit">Upload</button>
    </form>
    <ul id="files">…server-rendered current files…</ul>

    Two mechanisms, cleanly split:

    • Visibility is htmx-native: data-hx-indicator="find progress" + the htmx-indicator class show the bar only while the request is in flight.
    • Value is the bridge: installUploadProgress() maps htmx:xhr:progress onto 0–100 — monotonically within a request, because htmx fires that event for both the upload and the response-download phase, and the download’s small total would otherwise rewind the bar just before it finishes.
    CaseResponse
    Success200 — the new item fragment (lands afterbegin in #files) plus the pristine form as an hx-swap-oob fragment (file inputs can’t be reset from markup — the server re-sends the form; htmx re-initializes it) + an HX-Trigger toast
    Validation failure422 + HX-Retarget: #upload-errors + HX-Reswap: innerHTML with the field-errors fragment — the primary path stays attribute-declared; only the exception steers via headers
    Proxy 413May arrive before the app sees the request; htmx swaps nothing. The contract has an optional htmx:responseError toast snippet — and keep your app limit below the proxy’s so the friendly 422 wins
    No JS303 post/redirect/get (HX-Request branching, as in mutating-form)

    The server is the validator — client accept/size hints are UX only. Escape non-ASCII in HX-Trigger headers as \uXXXX (header values are latin-1 — see the undo-delete contract).

    Without JavaScript the same form posts a correct multipart request natively; progress and inline errors are enhancements.

    Swap the plain field for an hc-dropzone — nothing else changes. Drops assign the same native input and fire a normal change, so serialization, progress, the OOB reset and the 422 path are identical; the fresh form the server re-sends simply contains the pristine dropzone markup.

    The claims here — live progress reaching 100, the out-of-band form reset, the retargeted 422 — are pinned by a real-multipart browser test (test-browser/file-upload.spec.mjs).