Skip to content

File Upload

File uploadDrop files on it or browse. Each file shows as an attachment with a remove button; a file of another type or larger than max-size shows as an error.
Thymeleaf source
<sl:field th:field="*{uploadAttachments}">
<sl:field-label>Attachments</sl:field-label>
<sl:file-upload multiple accept="image/png,image/jpeg,application/pdf" max-size="10MB">
<sl:slot name="description">PNG, JPG, PDF up to 10 MB</sl:slot>
</sl:file-upload>
<sl:field-error/>
</sl:field>
Rendered HTML
<div class="field">
<label class="label field-label" for="uploadAttachments">Attachments</label>
<div class="file-upload" x-data="slFileUpload" data-max-size="10485760">
<input type="file" class="file-upload-input sl-sr-only" multiple accept="image/png,image/jpeg,application/pdf" aria-describedby="uploadAttachments-upload-description" id="uploadAttachments" name="uploadAttachments">
<div class="empty file-upload-dropzone" data-border="dashed">
<div class="empty-header">
<div class="empty-media" data-variant="icon">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M12 13v8"></path>
<path d="M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242"></path>
<path d="m8 17 4-4 4 4"></path>
</svg>
</div>
<div class="empty-title">Upload files</div>
<div class="empty-description" id="uploadAttachments-upload-description">PNG, JPG, PDF up to 10 MB</div>
</div>
<div class="empty-content">
<label class="button file-upload-browse" for="uploadAttachments">Browse files</label>
<button class="button file-upload-cancel" type="button" data-variant="outline">Cancel upload</button>
</div>
</div>
<ul class="file-upload-list" hidden></ul>
<div class="file-upload-status sl-sr-only" role="status" data-message-added="Added {0}." data-message-removed="Removed {0}." data-message-rejected="Could not add {0}: {1}." data-message-uploading="Uploading {0} files." data-message-uploading-one="Uploading {0}." data-message-uploaded="Uploaded {0} files." data-message-uploaded-one="Uploaded {0}." data-message-failed="The files could not be uploaded. Try again." data-message-cancelled="Upload cancelled."></div>
<template class="file-upload-template" data-kind="file" data-message-uploading="Uploading · {0}" data-message-uploading-indeterminate="Uploading" data-message-processing="Processing" data-message-failed="Upload failed" data-message-done="Uploaded">
<li class="file-upload-item">
<div class="attachment" data-state="idle" data-size="sm" data-width="full">
<div class="attachment-media">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"></path>
<path d="M14 2v5a1 1 0 0 0 1 1h5"></path>
</svg>
</div>
<div class="attachment-content">
<span class="attachment-title"></span>
<span class="attachment-description"></span>
</div>
<div class="attachment-actions">
<button class="button attachment-action" type="button" data-variant="ghost" data-size="icon-xs" aria-label="Remove {0}">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M18 6 6 18"></path>
<path d="m6 6 12 12"></path>
</svg>
</button>
</div>
</div>
</li>
</template>
<template class="file-upload-template" data-kind="rejected" data-message-type="File type not accepted" data-message-size="Larger than {0}" data-message-count="Only one file can be added">
<li class="file-upload-item">
<div class="attachment" data-state="error" data-size="sm" data-width="full">
<div class="attachment-media">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"></path>
<path d="M14 2v5a1 1 0 0 0 1 1h5"></path>
<path d="m14.5 12.5-5 5"></path>
<path d="m9.5 12.5 5 5"></path>
</svg>
</div>
<div class="attachment-content">
<span class="attachment-title"></span>
<span class="attachment-description"></span>
</div>
<div class="attachment-actions">
<button class="button attachment-action" type="button" data-variant="ghost" data-size="icon-xs" aria-label="Dismiss {0}">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M18 6 6 18"></path>
<path d="m6 6 12 12"></path>
</svg>
</button>
</div>
</div>
</li>
</template>
</div>
</div>

A file upload looks like an outlined empty state with an icon, a title, a description and a Browse button.

Underneath is a plain <input type="file">, visually hidden. It holds the files and the form submits it, so it binds with th:field as any other input does, to a MultipartFile, or with multiple to a List<MultipartFile>. The form needs enctype="multipart/form-data":

<form th:object="${upload}" th:action="@{/people/{id}/files(id=${person.id})}" method="post"
enctype="multipart/form-data">
<sl:field th:field="*{files}">
<sl:field-label>Attachments</sl:field-label>
<sl:file-upload multiple accept="image/png,image/jpeg,application/pdf" max-size="10MB">
<sl:slot name="description">PNG, JPG, PDF up to 10 MB</sl:slot>
</sl:file-upload>
<sl:field-error/>
</sl:field>
<sl:button type="submit">Upload</sl:button>
</form>
public class UploadForm {
@NotEmpty
private List<MultipartFile> files = new ArrayList<>();
// getter and setter
}

Every attribute that is not a prop goes to the input: th:field, name, id, required, disabled, aria-*, hx-*. class stays on the component. In an <sl:field> the input takes the field’s binding, its id (which the field’s label points at), aria-describedby and aria-invalid, and a field error colours the drop zone’s border.

Spring Boot limits uploads to 1 MB a file and 10 MB a request by default. Raise spring.servlet.multipart.max-file-size and max-request-size to match max-size.

With JavaScript (the slFileUpload Alpine component), the component does more:

  • Drop. Files dropped anywhere on the component are added. The drop zone takes the ring colour while files are dragged over it.
  • A list. Each file shows below the drop zone as an <sl:attachment> in its idle state, with its name, type and size and a button that removes it. The list is in the order the files were added.
  • Adding more. With multiple, picking or dropping files again adds them to the ones there; the browser would replace them. A file that is there already is not added twice. Without multiple, a new file replaces the old one.
  • Checks. A file that does not match accept, or is larger than max-size, is not added. It shows as an attachment in its error state, with the reason, until it is dismissed or files are added again. The browser’s picker applies accept by itself, but a drop does not, and the picker lets one choose any file.
  • Announcements. Added, removed and rejected files are announced once per change, in one polite live region: “Added report.pdf and photo.png.”

Everything still goes through the input: the script puts the files back with a DataTransfer (input.files = dt.files), so the form submits exactly what the list shows, and a drop dispatches change as a pick does, so hx-trigger="change" on the component works for both.

In a cardThe file upload as the content of a card.
Thymeleaf source
<sl:card>
<sl:card-header>
<sl:card-title>File Upload</sl:card-title>
<sl:card-description>Drag and drop or browse</sl:card-description>
</sl:card-header>
<sl:card-content>
<sl:file-upload th:field="*{uploadCardFiles}" multiple max-size="10MB">
<sl:slot name="description">PNG, JPG, PDF up to 10MB</sl:slot>
</sl:file-upload>
</sl:card-content>
</sl:card>
Rendered HTML
<div class="card">
<div class="card-header">
<div class="card-title">File Upload</div>
<div class="card-description">Drag and drop or browse</div>
</div>
<div class="card-content">
<div class="file-upload" x-data="slFileUpload" data-max-size="10485760">
<input type="file" class="file-upload-input sl-sr-only" multiple aria-describedby="uploadCardFiles-upload-description" id="uploadCardFiles" name="uploadCardFiles">
<div class="empty file-upload-dropzone" data-border="dashed">
<div class="empty-header">
<div class="empty-media" data-variant="icon">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M12 13v8"></path>
<path d="M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242"></path>
<path d="m8 17 4-4 4 4"></path>
</svg>
</div>
<div class="empty-title">Upload files</div>
<div class="empty-description" id="uploadCardFiles-upload-description">PNG, JPG, PDF up to 10MB</div>
</div>
<div class="empty-content">
<label class="button file-upload-browse" for="uploadCardFiles">Browse files</label>
<button class="button file-upload-cancel" type="button" data-variant="outline">Cancel upload</button>
</div>
</div>
<ul class="file-upload-list" hidden></ul>
<div class="file-upload-status sl-sr-only" role="status" data-message-added="Added {0}." data-message-removed="Removed {0}." data-message-rejected="Could not add {0}: {1}." data-message-uploading="Uploading {0} files." data-message-uploading-one="Uploading {0}." data-message-uploaded="Uploaded {0} files." data-message-uploaded-one="Uploaded {0}." data-message-failed="The files could not be uploaded. Try again." data-message-cancelled="Upload cancelled."></div>
<template class="file-upload-template" data-kind="file" data-message-uploading="Uploading · {0}" data-message-uploading-indeterminate="Uploading" data-message-processing="Processing" data-message-failed="Upload failed" data-message-done="Uploaded">
<li class="file-upload-item">
<div class="attachment" data-state="idle" data-size="sm" data-width="full">
<div class="attachment-media">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"></path>
<path d="M14 2v5a1 1 0 0 0 1 1h5"></path>
</svg>
</div>
<div class="attachment-content">
<span class="attachment-title"></span>
<span class="attachment-description"></span>
</div>
<div class="attachment-actions">
<button class="button attachment-action" type="button" data-variant="ghost" data-size="icon-xs" aria-label="Remove {0}">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M18 6 6 18"></path>
<path d="m6 6 12 12"></path>
</svg>
</button>
</div>
</div>
</li>
</template>
<template class="file-upload-template" data-kind="rejected" data-message-type="File type not accepted" data-message-size="Larger than {0}" data-message-count="Only one file can be added">
<li class="file-upload-item">
<div class="attachment" data-state="error" data-size="sm" data-width="full">
<div class="attachment-media">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"></path>
<path d="M14 2v5a1 1 0 0 0 1 1h5"></path>
<path d="m14.5 12.5-5 5"></path>
<path d="m9.5 12.5 5 5"></path>
</svg>
</div>
<div class="attachment-content">
<span class="attachment-title"></span>
<span class="attachment-description"></span>
</div>
<div class="attachment-actions">
<button class="button attachment-action" type="button" data-variant="ghost" data-size="icon-xs" aria-label="Dismiss {0}">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M18 6 6 18"></path>
<path d="m6 6 12 12"></path>
</svg>
</button>
</div>
</div>
</li>
</template>
</div>
</div>
</div>

The server checks the files anyway: accept and max-size only spare the user a round trip. A browser without JavaScript, or a request not made through the page, can send anything.

Without multiple, the title and button say so (“Upload a file”, “Browse”). The slots media, title and description replace the icon and the texts, and browse-label the button’s text.

One fileWithout multiple, a new file replaces the one picked before. The slots and browse-label change the icon, title and button.
Thymeleaf source
<sl:file-upload th:field="*{uploadAvatar}" accept="image/*" max-size="2MB" browse-label="Choose a photo">
<sl:slot name="media"><sl:icon name="image"/></sl:slot>
<sl:slot name="title">Profile photo</sl:slot>
<sl:slot name="description">An image of up to 2 MB</sl:slot>
</sl:file-upload>
Rendered HTML
<div class="file-upload" x-data="slFileUpload" data-max-size="2097152">
<input type="file" class="file-upload-input sl-sr-only" accept="image/*" aria-describedby="uploadAvatar-upload-description" id="uploadAvatar" name="uploadAvatar">
<div class="empty file-upload-dropzone" data-border="dashed">
<div class="empty-header">
<div class="empty-media" data-variant="icon">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<rect width="18" height="18" x="3" y="3" rx="2" ry="2"></rect>
<circle cx="9" cy="9" r="2"></circle>
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"></path>
</svg>
</div>
<div class="empty-title">Profile photo</div>
<div class="empty-description" id="uploadAvatar-upload-description">An image of up to 2 MB</div>
</div>
<div class="empty-content">
<label class="button file-upload-browse" for="uploadAvatar">Choose a photo</label>
<button class="button file-upload-cancel" type="button" data-variant="outline">Cancel upload</button>
</div>
</div>
<ul class="file-upload-list" hidden></ul>
<div class="file-upload-status sl-sr-only" role="status" data-message-added="Added {0}." data-message-removed="Removed {0}." data-message-rejected="Could not add {0}: {1}." data-message-uploading="Uploading {0} files." data-message-uploading-one="Uploading {0}." data-message-uploaded="Uploaded {0} files." data-message-uploaded-one="Uploaded {0}." data-message-failed="The files could not be uploaded. Try again." data-message-cancelled="Upload cancelled."></div>
<template class="file-upload-template" data-kind="file" data-message-uploading="Uploading · {0}" data-message-uploading-indeterminate="Uploading" data-message-processing="Processing" data-message-failed="Upload failed" data-message-done="Uploaded">
<li class="file-upload-item">
<div class="attachment" data-state="idle" data-size="sm" data-width="full">
<div class="attachment-media">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"></path>
<path d="M14 2v5a1 1 0 0 0 1 1h5"></path>
</svg>
</div>
<div class="attachment-content">
<span class="attachment-title"></span>
<span class="attachment-description"></span>
</div>
<div class="attachment-actions">
<button class="button attachment-action" type="button" data-variant="ghost" data-size="icon-xs" aria-label="Remove {0}">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M18 6 6 18"></path>
<path d="m6 6 12 12"></path>
</svg>
</button>
</div>
</div>
</li>
</template>
<template class="file-upload-template" data-kind="rejected" data-message-type="File type not accepted" data-message-size="Larger than {0}" data-message-count="Only one file can be added">
<li class="file-upload-item">
<div class="attachment" data-state="error" data-size="sm" data-width="full">
<div class="attachment-media">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"></path>
<path d="M14 2v5a1 1 0 0 0 1 1h5"></path>
<path d="m14.5 12.5-5 5"></path>
<path d="m9.5 12.5 5 5"></path>
</svg>
</div>
<div class="attachment-content">
<span class="attachment-title"></span>
<span class="attachment-description"></span>
</div>
<div class="attachment-actions">
<button class="button attachment-action" type="button" data-variant="ghost" data-size="icon-xs" aria-label="Dismiss {0}">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M18 6 6 18"></path>
<path d="m6 6 12 12"></path>
</svg>
</button>
</div>
</div>
</li>
</template>
</div>

With previews, a picked or dropped image shows a thumbnail in place of the file icon. It stays through the upload, dimmed while the file is sent, processed, or failed, as an attachment’s image is.

Image previewsWith previews, a picked image shows a thumbnail instead of the file icon, from a blob URL; other files keep theirs. A Content-Security-Policy must allow blob in img-src.
Thymeleaf source
<sl:file-upload th:field="*{uploadPhotos}" multiple accept="image/*,.pdf" max-size="10MB" previews>
<sl:slot name="media"><sl:icon name="images"/></sl:slot>
<sl:slot name="description">Images and PDFs up to 10 MB</sl:slot>
</sl:file-upload>
Rendered HTML
<div class="file-upload" x-data="slFileUpload" data-max-size="10485760">
<input type="file" class="file-upload-input sl-sr-only" multiple accept="image/*,.pdf" aria-describedby="uploadPhotos-upload-description" id="uploadPhotos" name="uploadPhotos">
<div class="empty file-upload-dropzone" data-border="dashed">
<div class="empty-header">
<div class="empty-media" data-variant="icon">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="m22 11-1.296-1.296a2.4 2.4 0 0 0-3.408 0L11 16"></path>
<path d="M4 8a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2"></path>
<circle cx="13" cy="7" r="1" fill="currentColor"></circle>
<rect x="8" y="2" width="14" height="14" rx="2"></rect>
</svg>
</div>
<div class="empty-title">Upload files</div>
<div class="empty-description" id="uploadPhotos-upload-description">Images and PDFs up to 10 MB</div>
</div>
<div class="empty-content">
<label class="button file-upload-browse" for="uploadPhotos">Browse files</label>
<button class="button file-upload-cancel" type="button" data-variant="outline">Cancel upload</button>
</div>
</div>
<ul class="file-upload-list" hidden></ul>
<div class="file-upload-status sl-sr-only" role="status" data-message-added="Added {0}." data-message-removed="Removed {0}." data-message-rejected="Could not add {0}: {1}." data-message-uploading="Uploading {0} files." data-message-uploading-one="Uploading {0}." data-message-uploaded="Uploaded {0} files." data-message-uploaded-one="Uploaded {0}." data-message-failed="The files could not be uploaded. Try again." data-message-cancelled="Upload cancelled."></div>
<template class="file-upload-template" data-kind="file" data-message-uploading="Uploading · {0}" data-message-uploading-indeterminate="Uploading" data-message-processing="Processing" data-message-failed="Upload failed" data-message-done="Uploaded">
<li class="file-upload-item">
<div class="attachment" data-state="idle" data-size="sm" data-width="full">
<div class="attachment-media">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"></path>
<path d="M14 2v5a1 1 0 0 0 1 1h5"></path>
</svg>
</div>
<div class="attachment-content">
<span class="attachment-title"></span>
<span class="attachment-description"></span>
</div>
<div class="attachment-actions">
<button class="button attachment-action" type="button" data-variant="ghost" data-size="icon-xs" aria-label="Remove {0}">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M18 6 6 18"></path>
<path d="m6 6 12 12"></path>
</svg>
</button>
</div>
</div>
</li>
</template>
<template class="file-upload-template" data-kind="rejected" data-message-type="File type not accepted" data-message-size="Larger than {0}" data-message-count="Only one file can be added">
<li class="file-upload-item">
<div class="attachment" data-state="error" data-size="sm" data-width="full">
<div class="attachment-media">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"></path>
<path d="M14 2v5a1 1 0 0 0 1 1h5"></path>
<path d="m14.5 12.5-5 5"></path>
<path d="m9.5 12.5 5 5"></path>
</svg>
</div>
<div class="attachment-content">
<span class="attachment-title"></span>
<span class="attachment-description"></span>
</div>
<div class="attachment-actions">
<button class="button attachment-action" type="button" data-variant="ghost" data-size="icon-xs" aria-label="Dismiss {0}">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M18 6 6 18"></path>
<path d="m6 6 12 12"></path>
</svg>
</button>
</div>
</div>
</li>
</template>
<template class="file-upload-template" data-kind="image">
<div class="attachment-media" data-variant="image">
<img alt="">
</div>
</template>
</div>
  • Which files. Images the browser can show (image/*), up to 20 MB: decoding a larger photo takes much memory and time. A file the browser cannot decode (HEIC, outside Safari) keeps its icon, and no broken image shows: the thumbnail takes the icon’s place only once it has loaded. SVG keeps its icon too. Shown in an <img> an SVG runs no script, but its blob: URL has the page’s origin, and opened from it (“Open image in new tab”) it would.
  • The image. Its alt is empty: the file’s name is right beside it.
  • The URLs. Each thumbnail is a blob: URL (URL.createObjectURL), made in the browser: the file is not sent for it. It is revoked when the file leaves the list (removed, replaced, the next change after an upload), and when the component is swapped out. A page htmx saves in its history gets the icons back, so it holds no URL that no longer works.

A policy without img-src, or with img-src 'self', blocks blob: images, so add it:

img-src 'self' blob:

Without it the files keep their icons and the upload works as before. The browser reports the blocked image in the console; after that, the page makes no more previews. See Content-Security-Policy.

With layout="grid", the picked files show as cards in columns that fill the width, instead of small attachments one below the other. With previews, a photo then shows as a large square thumbnail: big enough to judge, say, the photos of an inspection before they are sent with the rest of the form.

GridWith layout grid, the picked files show as cards in columns that fill the width, images with a large thumbnail, other files with their icon, rejected ones as errors.
Thymeleaf source
<sl:field th:field="*{uploadInspectionPhotos}">
<sl:field-label>Photos of defects</sl:field-label>
<sl:file-upload multiple accept="image/*,.pdf" max-size="10MB" layout="grid" previews>
<sl:slot name="media"><sl:icon name="images"/></sl:slot>
<sl:slot name="description">Images and PDFs up to 10 MB</sl:slot>
</sl:file-upload>
</sl:field>
Rendered HTML
<div class="field">
<label class="label field-label" for="uploadInspectionPhotos">Photos of defects</label>
<div class="file-upload" x-data="slFileUpload" data-layout="grid" data-max-size="10485760">
<input type="file" class="file-upload-input sl-sr-only" multiple accept="image/*,.pdf" aria-describedby="uploadInspectionPhotos-upload-description" id="uploadInspectionPhotos" name="uploadInspectionPhotos">
<div class="empty file-upload-dropzone" data-border="dashed">
<div class="empty-header">
<div class="empty-media" data-variant="icon">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="m22 11-1.296-1.296a2.4 2.4 0 0 0-3.408 0L11 16"></path>
<path d="M4 8a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2"></path>
<circle cx="13" cy="7" r="1" fill="currentColor"></circle>
<rect x="8" y="2" width="14" height="14" rx="2"></rect>
</svg>
</div>
<div class="empty-title">Upload files</div>
<div class="empty-description" id="uploadInspectionPhotos-upload-description">Images and PDFs up to 10 MB</div>
</div>
<div class="empty-content">
<label class="button file-upload-browse" for="uploadInspectionPhotos">Browse files</label>
<button class="button file-upload-cancel" type="button" data-variant="outline">Cancel upload</button>
</div>
</div>
<ul class="file-upload-list" hidden></ul>
<div class="file-upload-status sl-sr-only" role="status" data-message-added="Added {0}." data-message-removed="Removed {0}." data-message-rejected="Could not add {0}: {1}." data-message-uploading="Uploading {0} files." data-message-uploading-one="Uploading {0}." data-message-uploaded="Uploaded {0} files." data-message-uploaded-one="Uploaded {0}." data-message-failed="The files could not be uploaded. Try again." data-message-cancelled="Upload cancelled."></div>
<template class="file-upload-template" data-kind="file" data-message-uploading="Uploading · {0}" data-message-uploading-indeterminate="Uploading" data-message-processing="Processing" data-message-failed="Upload failed" data-message-done="Uploaded">
<li class="file-upload-item">
<div class="attachment" data-state="idle" data-orientation="vertical" data-width="full">
<div class="attachment-media">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"></path>
<path d="M14 2v5a1 1 0 0 0 1 1h5"></path>
</svg>
</div>
<div class="attachment-content">
<span class="attachment-title"></span>
<span class="attachment-description"></span>
</div>
<div class="attachment-actions">
<button class="button attachment-action" type="button" data-variant="ghost" data-size="icon-xs" aria-label="Remove {0}">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M18 6 6 18"></path>
<path d="m6 6 12 12"></path>
</svg>
</button>
</div>
</div>
</li>
</template>
<template class="file-upload-template" data-kind="rejected" data-message-type="File type not accepted" data-message-size="Larger than {0}" data-message-count="Only one file can be added">
<li class="file-upload-item">
<div class="attachment" data-state="error" data-orientation="vertical" data-width="full">
<div class="attachment-media">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"></path>
<path d="M14 2v5a1 1 0 0 0 1 1h5"></path>
<path d="m14.5 12.5-5 5"></path>
<path d="m9.5 12.5 5 5"></path>
</svg>
</div>
<div class="attachment-content">
<span class="attachment-title"></span>
<span class="attachment-description"></span>
</div>
<div class="attachment-actions">
<button class="button attachment-action" type="button" data-variant="ghost" data-size="icon-xs" aria-label="Dismiss {0}">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M18 6 6 18"></path>
<path d="m6 6 12 12"></path>
</svg>
</button>
</div>
</div>
</li>
</template>
<template class="file-upload-template" data-kind="image">
<div class="attachment-media" data-variant="image">
<img alt="">
</div>
</template>
</div>
</div>
<sl:file-upload th:field="*{photos}" multiple accept="image/*,.pdf" max-size="10MB" layout="grid" previews>
<sl:slot name="description">Images and PDFs up to 10 MB</sl:slot>
</sl:file-upload>
  • The cards. Each file is a vertical <sl:attachment> at the default size, filling its cell; the grid is the one of <sl:attachment-group layout="grid">, so both look alike. Other files show their icon in the same square, and a rejected file shows as an error card with the reason.
  • The buttons. The remove or dismiss button sits in the card’s corner, and is hidden while the files are sent.
  • The order. The cards are in the order of the files in the input, as in the list, so the focus order, the focus after a remove and the announcements are the same.

Without JavaScript nothing changes: there is no list.

A second submit renders the form again with the binding’s errors, as for any field. The input is marked aria-invalid and described by the error. A browser never keeps the files of a file input across a page load, so the user picks them again.

With errorsThe server checks the files anyway. A field error marks the input aria-invalid and the drop zone's border destructive.
Thymeleaf source
<sl:field th:field="*{uploadContract}">
<sl:field-label>Signed contract</sl:field-label>
<sl:file-upload accept=".pdf">
<sl:slot name="description">A PDF</sl:slot>
</sl:file-upload>
<sl:field-error/>
</sl:field>
Rendered HTML
<div class="field" data-invalid="true">
<label class="label field-label" for="uploadContract">Signed contract</label>
<div class="file-upload" x-data="slFileUpload">
<input type="file" class="file-upload-input sl-sr-only" accept=".pdf" aria-describedby="uploadContract-upload-description uploadContract-error" aria-invalid="true" id="uploadContract" name="uploadContract">
<div class="empty file-upload-dropzone" data-border="dashed">
<div class="empty-header">
<div class="empty-media" data-variant="icon">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M12 13v8"></path>
<path d="M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242"></path>
<path d="m8 17 4-4 4 4"></path>
</svg>
</div>
<div class="empty-title">Upload a file</div>
<div class="empty-description" id="uploadContract-upload-description">A PDF</div>
</div>
<div class="empty-content">
<label class="button file-upload-browse" for="uploadContract">Browse</label>
<button class="button file-upload-cancel" type="button" data-variant="outline">Cancel upload</button>
</div>
</div>
<ul class="file-upload-list" hidden></ul>
<div class="file-upload-status sl-sr-only" role="status" data-message-added="Added {0}." data-message-removed="Removed {0}." data-message-rejected="Could not add {0}: {1}." data-message-uploading="Uploading {0} files." data-message-uploading-one="Uploading {0}." data-message-uploaded="Uploaded {0} files." data-message-uploaded-one="Uploaded {0}." data-message-failed="The files could not be uploaded. Try again." data-message-cancelled="Upload cancelled."></div>
<template class="file-upload-template" data-kind="file" data-message-uploading="Uploading · {0}" data-message-uploading-indeterminate="Uploading" data-message-processing="Processing" data-message-failed="Upload failed" data-message-done="Uploaded">
<li class="file-upload-item">
<div class="attachment" data-state="idle" data-size="sm" data-width="full">
<div class="attachment-media">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"></path>
<path d="M14 2v5a1 1 0 0 0 1 1h5"></path>
</svg>
</div>
<div class="attachment-content">
<span class="attachment-title"></span>
<span class="attachment-description"></span>
</div>
<div class="attachment-actions">
<button class="button attachment-action" type="button" data-variant="ghost" data-size="icon-xs" aria-label="Remove {0}">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M18 6 6 18"></path>
<path d="m6 6 12 12"></path>
</svg>
</button>
</div>
</div>
</li>
</template>
<template class="file-upload-template" data-kind="rejected" data-message-type="File type not accepted" data-message-size="Larger than {0}" data-message-count="Only one file can be added">
<li class="file-upload-item">
<div class="attachment" data-state="error" data-size="sm" data-width="full">
<div class="attachment-media">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"></path>
<path d="M14 2v5a1 1 0 0 0 1 1h5"></path>
<path d="m14.5 12.5-5 5"></path>
<path d="m9.5 12.5 5 5"></path>
</svg>
</div>
<div class="attachment-content">
<span class="attachment-title"></span>
<span class="attachment-description"></span>
</div>
<div class="attachment-actions">
<button class="button attachment-action" type="button" data-variant="ghost" data-size="icon-xs" aria-label="Dismiss {0}">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M18 6 6 18"></path>
<path d="m6 6 12 12"></path>
</svg>
</button>
</div>
</div>
</li>
</template>
</div>
<div class="field-error" id="uploadContract-error">must not be empty</div>
</div>

A disabled input fades the Browse button, and the component takes no files.

DisabledA disabled input fades the Browse button, and the component takes no files.
Thymeleaf source
<sl:file-upload th:field="*{uploadArchive}" disabled>
<sl:slot name="description">Uploads are closed for this project.</sl:slot>
</sl:file-upload>
Rendered HTML
<div class="file-upload" x-data="slFileUpload">
<input type="file" class="file-upload-input sl-sr-only" aria-describedby="uploadArchive-upload-description" disabled id="uploadArchive" name="uploadArchive">
<div class="empty file-upload-dropzone" data-border="dashed">
<div class="empty-header">
<div class="empty-media" data-variant="icon">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M12 13v8"></path>
<path d="M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242"></path>
<path d="m8 17 4-4 4 4"></path>
</svg>
</div>
<div class="empty-title">Upload a file</div>
<div class="empty-description" id="uploadArchive-upload-description">Uploads are closed for this project.</div>
</div>
<div class="empty-content">
<label class="button file-upload-browse" for="uploadArchive">Browse</label>
<button class="button file-upload-cancel" type="button" data-variant="outline">Cancel upload</button>
</div>
</div>
<ul class="file-upload-list" hidden></ul>
<div class="file-upload-status sl-sr-only" role="status" data-message-added="Added {0}." data-message-removed="Removed {0}." data-message-rejected="Could not add {0}: {1}." data-message-uploading="Uploading {0} files." data-message-uploading-one="Uploading {0}." data-message-uploaded="Uploaded {0} files." data-message-uploaded-one="Uploaded {0}." data-message-failed="The files could not be uploaded. Try again." data-message-cancelled="Upload cancelled."></div>
<template class="file-upload-template" data-kind="file" data-message-uploading="Uploading · {0}" data-message-uploading-indeterminate="Uploading" data-message-processing="Processing" data-message-failed="Upload failed" data-message-done="Uploaded">
<li class="file-upload-item">
<div class="attachment" data-state="idle" data-size="sm" data-width="full">
<div class="attachment-media">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"></path>
<path d="M14 2v5a1 1 0 0 0 1 1h5"></path>
</svg>
</div>
<div class="attachment-content">
<span class="attachment-title"></span>
<span class="attachment-description"></span>
</div>
<div class="attachment-actions">
<button class="button attachment-action" type="button" data-variant="ghost" data-size="icon-xs" aria-label="Remove {0}">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M18 6 6 18"></path>
<path d="m6 6 12 12"></path>
</svg>
</button>
</div>
</div>
</li>
</template>
<template class="file-upload-template" data-kind="rejected" data-message-type="File type not accepted" data-message-size="Larger than {0}" data-message-count="Only one file can be added">
<li class="file-upload-item">
<div class="attachment" data-state="error" data-size="sm" data-width="full">
<div class="attachment-media">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"></path>
<path d="M14 2v5a1 1 0 0 0 1 1h5"></path>
<path d="m14.5 12.5-5 5"></path>
<path d="m9.5 12.5 5 5"></path>
</svg>
</div>
<div class="attachment-content">
<span class="attachment-title"></span>
<span class="attachment-description"></span>
</div>
<div class="attachment-actions">
<button class="button attachment-action" type="button" data-variant="ghost" data-size="icon-xs" aria-label="Dismiss {0}">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M18 6 6 18"></path>
<path d="m6 6 12 12"></path>
</svg>
</button>
</div>
</div>
</li>
</template>
</div>
  • The input is the only tab stop. Space or Enter opens the file picker, as on any file input; the Browse button is its <label> (<sl:button as="label">), so a click opens it too, and the input’s focus ring is drawn on the button.
  • The input’s name is the field’s label and the Browse button’s text; it is described by the drop zone’s description and the field’s description and error.
  • Each remove button is named after its file: “Remove report.pdf”. After a remove, the focus moves to the next file’s button, or back to the input.
  • Dropping is a pointer gesture; the keyboard path is the input.
  • An upload is announced once when it starts (“Uploading 3 files.”), and again when it fails, is cancelled, or ends while the component is still there. The percentages are not announced; each uploading or processing attachment is aria-busy. After a Cancel, the focus goes back to the input.
  • Its texts and announcements are messages (sl.file-upload.*); your own message files can reword them or add a language, as Translating explains.

The Browse button still opens the picker and the form submits the files. There is no drop and no list: the browser shows nothing of the picked files but what the hidden input would.

The component can be swapped in and out. The browser never restores a file input’s files, and htmx’s history restore renders the page’s markup again: the list is drawn from the input’s files whenever the component starts, so going back shows an empty upload, never files that would not be sent. A page saved in the history while uploading comes back as an upload that is not sending anything.

When the form is sent with htmx (hx-post on the form, or a boosted form), the component shows how far the upload is, with no attribute of its own. It takes part in every htmx request whose body carries its files, so the form needs enctype="multipart/form-data" or hx-encoding="multipart/form-data". The files go through the attachment’s states:

  1. Uploading. Each file shows the share of its bytes sent: “Uploading · 64%”.
  2. Processing, once the whole request is sent and the server is still working on it.
  3. The outcome. On success the server’s answer is swapped in, as for any htmx request. It usually replaces the component, with an empty form and what was uploaded, so the answer is what tells the user it worked. A toast or the focus on the new files does that well. If the component stays, its files show as “Uploaded” and leave the input, so a second submit does not send them again. On a failed request (an error status, no connection, a timeout), the files show “Upload failed” and stay in the input, so the user can submit again.
Statesidle draws a dashed border, uploading and processing shimmer the title, error turns destructive. The description says the state too.
Thymeleaf source
<sl:attachment state="idle" full-width>
<sl:attachment-media><sl:icon name="clock"/></sl:attachment-media>
<sl:attachment-content>
<sl:attachment-title>selected-file.pdf</sl:attachment-title>
<sl:attachment-description>Ready to upload</sl:attachment-description>
</sl:attachment-content>
<sl:attachment-actions>
<sl:attachment-action aria-label="Remove selected-file.pdf"><sl:icon name="x"/></sl:attachment-action>
</sl:attachment-actions>
</sl:attachment>
<sl:attachment state="uploading" full-width>
<sl:attachment-media><sl:spinner aria-hidden="true"/></sl:attachment-media>
<sl:attachment-content>
<sl:attachment-title>design-system.zip</sl:attachment-title>
<sl:attachment-description>Uploading · 64%</sl:attachment-description>
</sl:attachment-content>
<sl:attachment-actions>
<sl:attachment-action aria-label="Cancel upload of design-system.zip"><sl:icon name="x"/></sl:attachment-action>
</sl:attachment-actions>
</sl:attachment>
<sl:attachment state="processing" full-width>
<sl:attachment-media><sl:icon name="file-text"/></sl:attachment-media>
<sl:attachment-content>
<sl:attachment-title>market-research.pdf</sl:attachment-title>
<sl:attachment-description>Processing document</sl:attachment-description>
</sl:attachment-content>
<sl:attachment-actions>
<sl:attachment-action aria-label="Remove market-research.pdf"><sl:icon name="x"/></sl:attachment-action>
</sl:attachment-actions>
</sl:attachment>
<sl:attachment state="error" full-width>
<sl:attachment-media><sl:icon name="file-warning"/></sl:attachment-media>
<sl:attachment-content>
<sl:attachment-title>financial-model.xlsx</sl:attachment-title>
<sl:attachment-description>Upload failed. Try again.</sl:attachment-description>
</sl:attachment-content>
<sl:attachment-actions>
<sl:attachment-action aria-label="Retry upload of financial-model.xlsx"><sl:icon name="refresh-cw"/></sl:attachment-action>
<sl:attachment-action aria-label="Remove financial-model.xlsx"><sl:icon name="x"/></sl:attachment-action>
</sl:attachment-actions>
</sl:attachment>
<sl:attachment state="done" full-width>
<sl:attachment-media><sl:icon name="check"/></sl:attachment-media>
<sl:attachment-content>
<sl:attachment-title>uploaded-report.pdf</sl:attachment-title>
<sl:attachment-description>Uploaded · 1.8 MB</sl:attachment-description>
</sl:attachment-content>
<sl:attachment-actions>
<sl:attachment-action aria-label="Remove uploaded-report.pdf"><sl:icon name="x"/></sl:attachment-action>
</sl:attachment-actions>
</sl:attachment>
Rendered HTML
<div class="attachment" data-state="idle" data-width="full">
<div class="attachment-media">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<circle cx="12" cy="12" r="10"></circle>
<path d="M12 6v6l4 2"></path>
</svg>
</div>
<div class="attachment-content">
<span class="attachment-title">selected-file.pdf</span>
<span class="attachment-description">Ready to upload</span>
</div>
<div class="attachment-actions">
<button class="button attachment-action" type="button" data-variant="ghost" data-size="icon-xs" aria-label="Remove selected-file.pdf">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M18 6 6 18"></path>
<path d="m6 6 12 12"></path>
</svg>
</button>
</div>
</div>
<div class="attachment" data-state="uploading" data-width="full" aria-busy="true">
<div class="attachment-media">
<svg xmlns="http://www.w3.org/2000/svg" class="icon spinner" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" role="status" aria-label="Loading" aria-hidden="true">
<path d="M21 12a9 9 0 1 1-6.219-8.56"></path>
</svg>
</div>
<div class="attachment-content">
<span class="attachment-title">design-system.zip</span>
<span class="attachment-description">Uploading · 64%</span>
</div>
<div class="attachment-actions">
<button class="button attachment-action" type="button" data-variant="ghost" data-size="icon-xs" aria-label="Cancel upload of design-system.zip">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M18 6 6 18"></path>
<path d="m6 6 12 12"></path>
</svg>
</button>
</div>
</div>
<div class="attachment" data-state="processing" data-width="full" aria-busy="true">
<div class="attachment-media">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"></path>
<path d="M14 2v5a1 1 0 0 0 1 1h5"></path>
<path d="M10 9H8"></path>
<path d="M16 13H8"></path>
<path d="M16 17H8"></path>
</svg>
</div>
<div class="attachment-content">
<span class="attachment-title">market-research.pdf</span>
<span class="attachment-description">Processing document</span>
</div>
<div class="attachment-actions">
<button class="button attachment-action" type="button" data-variant="ghost" data-size="icon-xs" aria-label="Remove market-research.pdf">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M18 6 6 18"></path>
<path d="m6 6 12 12"></path>
</svg>
</button>
</div>
</div>
<div class="attachment" data-state="error" data-width="full">
<div class="attachment-media">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"></path>
<path d="M12 9v4"></path>
<path d="M12 17h.01"></path>
</svg>
</div>
<div class="attachment-content">
<span class="attachment-title">financial-model.xlsx</span>
<span class="attachment-description">Upload failed. Try again.</span>
</div>
<div class="attachment-actions">
<button class="button attachment-action" type="button" data-variant="ghost" data-size="icon-xs" aria-label="Retry upload of financial-model.xlsx">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"></path>
<path d="M21 3v5h-5"></path>
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"></path>
<path d="M8 16H3v5"></path>
</svg>
</button>
<button class="button attachment-action" type="button" data-variant="ghost" data-size="icon-xs" aria-label="Remove financial-model.xlsx">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M18 6 6 18"></path>
<path d="m6 6 12 12"></path>
</svg>
</button>
</div>
</div>
<div class="attachment" data-width="full">
<div class="attachment-media">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M20 6 9 17l-5-5"></path>
</svg>
</div>
<div class="attachment-content">
<span class="attachment-title">uploaded-report.pdf</span>
<span class="attachment-description">Uploaded · 1.8 MB</span>
</div>
<div class="attachment-actions">
<button class="button attachment-action" type="button" data-variant="ghost" data-size="icon-xs" aria-label="Remove uploaded-report.pdf">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M18 6 6 18"></path>
<path d="m6 6 12 12"></path>
</svg>
</button>
</div>
</div>

The browser reports the progress of the whole request, not of each file. The files go into the request one after the other, in the list’s order, so each one’s share is worked out from where its bytes are in the request. The figure is off by the few bytes of the form’s other fields, which hardly shows.

While the files are sent, they cannot be added or removed: the input is disabled and the remove buttons are hidden. A Cancel upload button takes the Browse button’s place. It aborts the request (htmx:abort), and the files are back as they were, ready to send again. All files go in one request, so cancelling stops them all.

  • htmx 2 sends with an XMLHttpRequest, and the component follows its upload.
  • htmx 4 sends with fetch(), which reports no upload progress. For a request that carries the files, the component gives htmx a fetch of its own (ctx.fetch), which sends with an XMLHttpRequest. Everything else (swapping, HX-* headers, history, htmx:abort) is still htmx’s. htmx 4 aborts a request after 60 seconds by default; for large uploads, raise its defaultTimeout or set hx-config on the form. htmx 4 also swaps an error answer in, where htmx 2 leaves the page alone: if that answer replaces the component, it shows what went wrong instead of the failed files.

Without htmx, a plain POST, nothing of this happens: the browser shows its own progress, if any.

Spring Boot answers a request larger than spring.servlet.multipart.max-request-size with an error, which shows as a failed upload: set the limits to what the form may send.

AttributeValuesDefaultDescription
multipleboolean—Take more than one file. Picking or dropping files adds them to the ones already there.
acceptstring—The file types to take, as the input's accept: extensions (.pdf), MIME types (application/pdf) or image/*, separated by commas. A dropped file of another type is not added.
max-sizestring—The largest file to take, as Spring Boot writes sizes: 10MB, 500KB. A larger file is not added.
levelnumber—Makes the title a heading of this level (role="heading").
previewsboolean—Show a thumbnail of each picked image (up to 20 MB, no SVG) instead of the file icon. The thumbnail is a blob: URL: a Content-Security-Policy must allow it in img-src, or the icon stays.
layoutlist gridlistlist shows the picked files one below the other, as small attachments; grid as cards in columns that fill the width, the grid of sl:attachment-group layout="grid": with previews, for photos one wants to see before sending them.
browse-labelstring—The text of the Browse button. Defaults to "Browse files", or "Browse" without multiple.

Every prop can also be set from an expression: th:multiple="${...}". Any other attribute passes through to the rendered element, and class is appended.