Upload
The upload component provides a drag-and-drop file upload zone with automatic progress tracking, file validation, and error display. Files begin uploading immediately when selected or dropped. The component is fully self-contained and does not need to be wrapped in a form.
Required LiveView configuration
Every upload needs two things in your LiveView: an allow_upload/3
call
in mount/3
and a handle_event/3
clause for "cancel-upload". Each allow_upload
call configures
one independent upload — you can have as many as you need on a single page,
each with its own file type restrictions, entry limits, and size limits.
Step 1: Configure uploads in mount/3
Call allow_upload/3 for each upload you need. The first argument is
an atom name (e.g. :avatar, :documents) that you'll
reference in the template via @uploads.name.
Key options:
| Option | Required? | Description |
|---|---|---|
accept |
Yes |
File types to accept. Use :any for all types, or a list
of extensions like ~w(.pdf .docx), or MIME types
like ~w(image/*).
|
max_entries |
Yes |
Maximum number of files that can be uploaded at once. Use 1
for single-file uploads, or a higher number for multi-file.
|
auto_upload |
Yes |
Must be true. This makes files upload immediately when
selected or dropped, rather than waiting for a form submit.
|
progress |
Yes |
A function reference like &handle_progress/3. Called
on each progress update. You use this to consume files once they
finish uploading (see Step 2).
|
max_file_size |
No |
Maximum file size in bytes. Defaults to 8 MB. Example: 1_000_000
for 1 MB.
|
Example — a page with two independent uploads:
def mount(_params, _session, socket) do
socket =
socket
# Single image upload (max 1 JPG/PNG, up to 5 MB)
|> allow_upload(:avatar,
accept: ~w(.jpg .jpeg .png),
max_entries: 1,
max_file_size: 5_000_000,
auto_upload: true,
progress: &handle_progress/3
)
# Multi-file PDF upload (up to 3 files, default 8 MB each)
|> allow_upload(:documents,
accept: ~w(.pdf),
max_entries: 3,
auto_upload: true,
progress: &handle_progress/3
)
{:ok, socket}
end
Step 2: Write the progress callback
The progress callback is called every time an upload chunk is received. When
entry.done?
is true, the file has finished uploading
and you must call consume_uploaded_entries/3
to process it. Inside
the consumer function, you receive a map with the temporary file path
and the entry metadata.
defp handle_progress(_upload_name, entry, socket) do
if entry.done? do
completed =
consume_uploaded_entries(socket, entry.upload_config, fn %{path: path}, entry ->
# Copy the temp file to permanent storage, save to DB, etc.
#
# NEVER trust entry.client_name: it is attacker-controlled and may
# contain path traversal ("../"). Generate a random filename and
# store uploads OUTSIDE priv/static (which is served publicly, so an
# uploaded .html would be a stored-XSS vector). upload_dir points at
# a private directory, e.g. Path.join(System.tmp_dir!(), "uploads").
ext = entry.client_name |> Path.basename() |> Path.extname()
dest = Path.join(upload_dir, "#{Ecto.UUID.generate()}#{ext}")
File.cp!(path, dest)
{:ok, %{name: entry.client_name, path: dest}}
end)
# Do something with the completed files (assign, notify, etc.)
{:noreply, assign(socket, :uploaded_files, completed)}
else
{:noreply, socket}
end
end
entry.client_name. It is
supplied by the client and can contain path traversal (../).
Always derive a fresh, random filename and write uploads to a directory outside
priv/static
— files under priv/static
are served publicly, so an uploaded .html
becomes a stored-XSS vector.
Step 3: Handle cancel and validate events
The component sends a "cancel-upload" event when a user cancels
an in-progress upload. Add this handler to your LiveView:
def handle_event("cancel-upload", %{"ref" => ref, "upload" => upload_name}, socket) do
{:noreply, cancel_upload(socket, String.to_existing_atom(upload_name), ref)}
end
The component also sends a "validate-upload" event on file selection.
You need a handler for this, but it can simply return the socket unchanged:
def handle_event("validate-upload", _params, socket) do
{:noreply, socket}
end
Step 4: Add the component to your template
Drop <Reg.upload_file> anywhere in your template.
No wrapping form is needed — the component is self-contained.
<Reg.upload_file
id="avatar-upload"
upload={@uploads.avatar}
label="Profile photo"
hint="JPG or PNG, max 5 MB"
/>
<Reg.upload_file
id="doc-upload"
upload={@uploads.documents}
label="Supporting documents"
hint="PDF files, up to 3"
/>
Component attributes
| Attribute | Type | Default | Description |
|---|---|---|---|
id |
:string |
required | Unique DOM ID for the upload component |
upload |
UploadConfig |
required | The upload config from @uploads.name |
label |
:string |
"Upload files" | Label text above the dropzone. Set to nil to hide. |
hint |
:string |
nil |
Hint text shown inside the dropzone |
cancel_event |
:string |
"cancel-upload" | Event name pushed when cancel button is clicked |
class |
:string |
nil |
Additional CSS class for the wrapper |
Live examples
Basic single-file upload
A single-file upload accepting any type.
Example
Markup
Source markup is unavailable in this environment. Markup extraction relies
on debug_heex_annotations and the project source files, which
are only present during development.
Multi-file upload
Accepts up to 3 files at once.
Example
Markup
Source markup is unavailable in this environment. Markup extraction relies
on debug_heex_annotations and the project source files, which
are only present during development.
Image-only upload
Restricts to image file types. Attempting to upload non-image files will show a validation error.
Example
Markup
Source markup is unavailable in this environment. Markup extraction relies
on debug_heex_annotations and the project source files, which
are only present during development.
Size-constrained upload
Limits file size to 1 MB. Files over 1 MB will trigger a "File is too large" error.
Example
Markup
Source markup is unavailable in this environment. Markup extraction relies
on debug_heex_annotations and the project source files, which
are only present during development.
Custom label
Example
Markup
Source markup is unavailable in this environment. Markup extraction relies
on debug_heex_annotations and the project source files, which
are only present during development.
No label variant
Pass label= to remove the label entirely.
This is useful when the upload sits inside a form section that already has a heading.
Example
Markup
Source markup is unavailable in this environment. Markup extraction relies
on debug_heex_annotations and the project source files, which
are only present during development.