Components File upload

Petal Pro is the full SaaS app this is built for

Auth, billing, admin, and Claude Code integration included. One purchase, unlimited projects.

File upload

LiveView uploads with the chrome built in - drop zones, progress, cancel, avatar and gallery presets. It is ordinary allow_upload/3 underneath.
The dropzone

Hand it an @uploads.<name> from allow_upload/3 and it renders the whole surface. The hint line is derived from the config, so the accepted types, the size cap and the file count stay true without you repeating them.

heex
<.file_upload
upload={PetalComponents.Showcase.FileUpload.config("sxfu0", [])}
label="Drop your documents here"
/>
Files in flight

Each entry gets a type icon, its humanised size, a progress bar carrying role=progressbar, and a cancel button named after the file. Progress comes straight from entry.progress, so the bar moves as LiveView reports chunks.

  • q3-forecast.pdf
  • board-notes.docx
heex
<.file_upload
upload={PetalComponents.Showcase.FileUpload.uploading_config()}
label="Drop your documents here"
/>
Errors, config level and per entry

upload_errors/1 and upload_errors/2 are rendered as plain English. The config-level message sits above the list and is described from the wrapper; the per-entry one sits inside its row, described from that row's cancel button, so a screen reader reads the file and the problem together when you tab to it.

You have selected too many files

  • raw-scan.pdf This file is too large
  • cover-letter.pdf
heex
<.file_upload
upload={PetalComponents.Showcase.FileUpload.error_config()}
label="Attach up to two files"
/>
Compact

A browse button and the list, no zone. For forms where a full dashed rectangle would shout too loudly. Drag and drop still works, the button is the drop target.

PDF or DOCX, up to 8 MB
heex
<.file_upload
upload={PetalComponents.Showcase.FileUpload.config("sxfu3", max_entries: 1)}
variant="compact"
label="Attach a file"
/>

A grid of tiles with the cancel button and progress on the tile itself, plus an add tile that bows out at max_entries. The first two photos are already saved: they come from your database through the :existing slot as plain images, with your own remove event rather than cancel_upload. The third is an upload in flight, and its thumbnail is drawn from the browser's copy of the file by live_img_preview, so on this static page it stays a quiet placeholder with its progress bar until a real LiveView fills it in. The URLs here are inline SVG stand-ins; swap in real photos and nothing else changes.

Avatar

One circular target with a replace overlay on hover and on keyboard focus. Empty on the left; on the right the photo already on the account, handed over as a URL through the :existing slot. Either way, picking a file takes over the circle.

Profile photo PNG or JPG, up to 2 MB
Profile photo PNG or JPG, up to 2 MB
heex
<div class="flex flex-wrap items-start gap-8">
<.file_upload
upload={
PetalComponents.Showcase.FileUpload.config("sxfu5",
name: :avatar,
accept: ~w(.png .jpg),
max_entries: 1,
max_file_size: 2_000_000
)
}
variant="avatar"
label="Profile photo"
/>
<.file_upload
upload={
PetalComponents.Showcase.FileUpload.config("sxfu6",
name: :avatar,
accept: ~w(.png .jpg),
max_entries: 1,
max_file_size: 2_000_000
)
}
variant="avatar"
label="Profile photo"
>
<:existing
src="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1 1'><defs><linearGradient id='a' x1='0' y1='0' x2='0.7' y2='1'><stop offset='0' stop-color='%23c7d2fe'/><stop offset='1' stop-color='%234338ca'/></linearGradient></defs><rect width='1' height='1' fill='url(%23a)'/></svg>"
name="Current photo"
/>
</.file_upload>
</div>
Your own entry row

The :entry slot hands you the %Phoenix.LiveView.UploadEntry{} and replaces the default row outright, so translated copy or a different layout costs one slot. You own the progress and cancel affordances once you take it over.

  • q3-forecast.pdf 100%
  • board-notes.docx 42%
heex
<.file_upload
upload={PetalComponents.Showcase.FileUpload.uploading_config()}
label="Drop your documents here"
>
<:entry :let={entry}>
<div class="flex items-center justify-between w-full gap-3 text-sm">
<span class="font-medium truncate">{entry.client_name}</span>
<span class="text-gray-500 tabular-nums dark:text-gray-400">{entry.progress}%</span>
</div>
</:entry>
</.file_upload>
Properties
Attribute Type Default Description
cancel_event string "cancel-upload" phx-click event name emitted by each entry's cancel button. The parent LiveView handles it and calls `cancel_upload/3` with the entry ref, sent as `phx-value-ref`.
cancel_label string "Cancel upload of" prefix for each cancel button's accessible name, joined with the file name
cancel_target any nil phx-target for the cancel event when used inside a LiveComponent
class any nil CSS class for the outer wrapper
description string nil hint line under the label. When nil it is derived from the config - accepted extensions from `:accept`, the cap from `:max_file_size`, the count from `:max_entries` (e.g. "PNG or JPG, up to 8 MB, max 4 files"). Pass "" for no line.
id string nil id for the wrapper; the ARIA relationships are derived from it. Defaults to the upload ref.
label string nil heading text inside the drop zone
remove_label string "Remove" prefix for the accessible name of each `:existing` item's remove button, joined with that item's name
rest global
upload* {:struct, Phoenix.LiveView.UploadConfig} the upload config from `allow_upload/3`, e.g. `@uploads.avatar`
variant string "dropzone" dropzone = full dashed zone; compact = browse button + list, no zone; avatar = single circular image with a replace overlay; gallery = grid of preview tiles
one of: "dropzone", "compact", "avatar", "gallery"
:entry slot slot optional custom rendering for each entry; receives the `%Phoenix.LiveView.UploadEntry{}` and replaces the default entry row
:existing slot slot files already uploaded and stored, shown by URL before the in-flight entries. Nothing here touches the upload config - see the module docs