LiveView Integration

Copy Markdown View Source

PhxMediaLibrary provides two approaches for handling media uploads in LiveView:

  1. MediaLive LiveComponent (recommended) — a single line of template code that handles everything: drag-and-drop, previews, progress, persistence, gallery display, and deletion.

  2. Custom Upload UI — use the lower-level LiveUpload helpers and build your own form, drop zone, and event handlers for full control.

Setup

1. Tailwind CSS source path

Make sure your assets/css/app.css includes the library's source path so Tailwind v4 can detect the component classes (see Getting Started — Tailwind CSS Setup):

/* PhxMediaLibrary — include both paths to support Hex deps and path deps.
   Tailwind v4 silently ignores paths that don't exist. */
@source "../../deps/phx_media_library/lib";
@source "../../../phx_media_library/lib";

Without this, styled elements like hover overlays, progress bars, and buttons may be missing from the generated CSS.

2. Component imports

Add the component imports to your my_app_web.ex:

defp html_helpers do
  quote do
    # ... existing imports
    import PhxMediaLibrary.Components
    import PhxMediaLibrary.ViewHelpers
  end
end

The MediaLive LiveComponent eliminates all upload boilerplate. No use PhxMediaLibrary.LiveUpload, no handle_event clauses, no allow_upload, no consume_media. The component handles everything.

Minimal Example

defmodule MyAppWeb.PostLive.Show do
  use MyAppWeb, :live_view

  def mount(%{"id" => id}, _session, socket) do
    post = Posts.get_post!(id)
    {:ok, assign(socket, :post, post)}
  end

  def render(assigns) do
    ~H"""
    <Layouts.app flash={@flash}>
      <h1>{@post.title}</h1>

      <.live_component
        module={PhxMediaLibrary.MediaLive}
        id="post-images"
        model={@post}
        collection={:images}
      />
    </Layouts.app>
    """
  end
end

That's it. You get:

  • Drag-and-drop upload zone with visual feedback
  • Live image previews for selected files
  • Upload progress bars per entry
  • Error display (file too large, wrong type, too many files)
  • Cancel buttons for pending uploads
  • An "Upload N file(s)" submit button
  • A media gallery grid with thumbnails
  • Delete-on-hover for each media item
  • Dark mode support

Full Options

<.live_component
  module={PhxMediaLibrary.MediaLive}
  id="album-photos"
  model={@album}
  collection={:photos}
  max_file_size={20_000_000}
  max_entries={20}
  responsive={true}
  upload_label="Drop photos here"
  upload_sublabel="JPG, PNG, WebP, GIF up to 20MB"
  compact={false}
  columns={4}
  conversion={:thumb}
  show_gallery={true}
  class="my-custom-class"
  upload_class="my-dropzone"
  gallery_class="mt-6 grid gap-4 grid-cols-2 sm:grid-cols-3 lg:grid-cols-4"
  button_class="btn btn-primary w-full"
/>

Available Options

OptionTypeDefaultDescription
idstringRequired. Unique DOM id
modelstructRequired. The Ecto struct (e.g. @album)
collectionatomRequired. Collection name (e.g. :photos)
max_file_sizeintegernilOverride collection's max file size (bytes)
max_entriesintegernilOverride how many files can be selected at once
responsivebooleanfalseGenerate responsive images on upload
upload_labelstringnilLabel text above the drop zone
upload_sublabelstringnilSecondary text (e.g. accepted formats)
compactbooleanfalseCompact single-line drop zone layout
columnsinteger4Gallery grid columns (2–6)
conversionatomnilConversion for gallery thumbnails
show_gallerybooleantrueShow the gallery below the upload zone
classstringnilAdditional CSS classes on the outer wrapper
upload_classstringnilCSS classes on the drop zone wrapper
gallery_classstringnilReplaces default gallery grid classes
button_classstringnilReplaces default submit button classes

How Upload Limits Are Derived

When max_file_size, max_entries, or accept types are not explicitly provided, they are derived from your schema's collection configuration automatically:

Collection configDerived max_entries
single_file: true1 — single file picker (no multiple attr)
max_files: NN — multi-file picker
Neither set10 — sensible default, multi-file picker

For example, given these collections:

def media_collections do
  [
    collection(:photos, accepts: ~w(image/jpeg image/png image/webp)),
    collection(:cover, single_file: true, accepts: ~w(image/jpeg image/png)),
    collection(:documents, max_files: 5, accepts: ~w(application/pdf))
  ]
end
  • :photos → multi-file picker, up to 10 files (default)
  • :cover → single-file picker, exactly 1 file
  • :documents → multi-file picker, up to 5 files

You can always override at the component level with the max_entries option, which takes precedence over the collection config. Similarly, max_file_size overrides the collection's :max_size, and accept types are derived from the collection's :accepts list.

Customizing Styles

The component ships with sensible default styles using plain Tailwind utility classes (bg-zinc-50, text-blue-600, dark:bg-zinc-800, etc.). These work out of the box with any Tailwind-based project.

If your app uses a component library like daisyUI, or you simply want to match your own design system, use the upload_class, gallery_class, and button_class options to override the defaults:

OptionWhat it controlsDefault behavior
upload_classThe <div> wrapping the drop zoneNo extra classes (the drop zone itself has its own styles)
gallery_classThe gallery grid container (phx-update="stream")"mt-6 grid gap-4" + responsive column classes derived from columns
button_classThe "Upload N file(s)" submit buttonBlue rounded button with hover/focus/dark states

When you pass a value, it replaces the default classes entirely (not merged), giving you full control. When nil (the default), the built-in styles are used.

Example: daisyUI integration

<.live_component
  module={PhxMediaLibrary.MediaLive}
  id="album-photos"
  model={@album}
  collection={:photos}
  button_class="btn btn-primary w-full"
  gallery_class="mt-6 grid gap-4 grid-cols-2 sm:grid-cols-3 lg:grid-cols-4"
/>

Example: fully custom styles

<.live_component
  module={PhxMediaLibrary.MediaLive}
  id="post-docs"
  model={@post}
  collection={:documents}
  class="p-6 bg-gray-50 rounded-2xl"
  upload_class="max-w-md mx-auto"
  button_class="mt-4 px-6 py-3 bg-emerald-600 text-white rounded-full hover:bg-emerald-700 font-medium w-full text-center"
  gallery_class="mt-8 grid gap-6 grid-cols-1 sm:grid-cols-2"
/>

Tip: The class option adds classes to the outermost wrapper <div> and is always merged with the base "phx-media-live" class. The other three options (upload_class, gallery_class, button_class) replace their respective defaults when set.

Reacting to Uploads and Deletions

The component sends messages to the parent LiveView so you can update related state (counters, summaries, etc.):

def handle_info({PhxMediaLibrary.MediaLive, {:uploaded, :photos, media_items}}, socket) do
  # media_items is a list of newly uploaded %Media{} structs
  {:noreply, assign(socket, :photo_count, socket.assigns.photo_count + length(media_items))}
end

def handle_info({PhxMediaLibrary.MediaLive, {:deleted, :photos, media}}, socket) do
  # media is the deleted %Media{} struct
  {:noreply, assign(socket, :photo_count, max(0, socket.assigns.photo_count - 1))}
end

You can safely ignore these messages if you don't need them.

Multiple Collections on One Page

Use multiple MediaLive components with different id and collection values:

<%!-- Cover image (single file, compact) --%>
<.live_component
  module={PhxMediaLibrary.MediaLive}
  id="album-cover"
  model={@album}
  collection={:cover}
  compact={true}
  upload_sublabel="JPG, PNG, WebP up to 10MB"
/>

<%!-- Photo gallery (multi-file) --%>
<.live_component
  module={PhxMediaLibrary.MediaLive}
  id="album-photos"
  model={@album}
  collection={:photos}
  responsive={true}
  upload_label="Drop photos here"
  upload_sublabel="JPG, PNG, WebP, GIF up to 20MB"
  columns={4}
/>

Each component manages its own upload configuration and media stream independently.


Approach 2: Custom Upload UI

For full control over the upload experience, use the lower-level LiveUpload helpers and build your own form, event handlers, and template.

Important: The <.media_upload> function component renders its own internal <.form>. You must not wrap it inside another <form> tag — nested forms are invalid HTML and will silently break file uploads. For custom UIs, use <.live_file_input> directly inside your own <.form phx-submit="...">.

Complete Custom Example

defmodule MyAppWeb.PostLive.Edit do
  use MyAppWeb, :live_view
  use PhxMediaLibrary.LiveUpload

  def mount(%{"id" => id}, _session, socket) do
    post = Posts.get_post!(id)

    {:ok,
     socket
     |> assign(:post, post)
     |> allow_media_upload(:images, model: post, collection: :images)
     |> stream_existing_media(:media, post, :images)}
  end

  def handle_event("validate", _params, socket) do
    {:noreply, socket}
  end

  def handle_event("save_media", _params, socket) do
    case consume_media(socket, :images, socket.assigns.post, :images, notify: self()) do
      {:ok, media_items} ->
        {:noreply,
         socket
         |> stream_media_items(:media, media_items)
         |> put_flash(:info, "Uploaded #{length(media_items)} file(s)")}

      {:error, reason} ->
        {:noreply, put_flash(socket, :error, "Upload failed: #{inspect(reason)}")}
    end
  end

  def handle_event("delete_media", %{"id" => id}, socket) do
    case delete_media_by_id(id, notify: self()) do
      :ok -> {:noreply, stream_delete_by_dom_id(socket, :media, "media-#{id}")}
      {:error, reason} -> {:noreply, put_flash(socket, :error, inspect(reason))}
    end
  end

  def handle_event("cancel_upload", %{"ref" => ref}, socket) do
    {:noreply, cancel_upload(socket, :images, ref)}
  end

  # Optional: react to media lifecycle events
  def handle_info({:media_added, _media_items}, socket), do: {:noreply, socket}
  def handle_info({:media_removed, _media}, socket), do: {:noreply, socket}
  def handle_info({:media_error, reason}, socket) do
    {:noreply, put_flash(socket, :error, "Media error: #{inspect(reason)}")}
  end
end

Custom Template

Build your own <.form> with <.live_file_input> inside it. This is the key difference from using <.media_upload> — you own the form, so submit works correctly:

<.form
  for={%{}}
  id="media-upload-form"
  phx-change="validate"
  phx-submit="save_media"
>
  <%!-- Drop zone --%>
  <div phx-drop-target={@uploads.images.ref}>
    <label class="flex flex-col items-center justify-center w-full min-h-[180px]
                   border-2 border-dashed rounded-xl cursor-pointer
                   border-zinc-300 bg-zinc-50
                   hover:border-blue-400 hover:bg-blue-50/50
                   phx-drop-target-active:border-blue-500 phx-drop-target-active:bg-blue-50">
      <p class="text-sm text-zinc-700">
        <span class="text-blue-600">Click to upload</span> or drag and drop
      </p>
      <p class="mt-1 text-xs text-zinc-500">JPG, PNG, WebP up to 10MB</p>

      <.live_file_input upload={@uploads.images} class="sr-only" />
    </label>
  </div>

  <%!-- Entry previews with progress --%>
  <div :for={entry <- @uploads.images.entries} class="flex items-center gap-3 mt-3">
    <.live_img_preview :if={String.starts_with?(entry.client_type, "image/")}
      entry={entry} class="w-12 h-12 rounded object-cover" />

    <div class="flex-1">
      <p class="text-sm truncate">{entry.client_name}</p>
      <div class="w-full h-1.5 bg-zinc-200 rounded-full mt-1">
        <div class="h-full bg-blue-500 rounded-full" style={"width: #{entry.progress}%"} />
      </div>
    </div>

    <button type="button" phx-click="cancel_upload" phx-value-ref={entry.ref}
            class="text-zinc-400 hover:text-red-500">
      ✕
    </button>
  </div>

  <%!-- Upload errors --%>
  <p :for={err <- upload_errors(@uploads.images)}
     class="text-sm text-red-600 mt-2">
    {translate_upload_error(err)}
  </p>

  <%!-- Submit button --%>
  <button :if={@uploads.images.entries != []} type="submit"
          class="mt-4 px-4 py-2 bg-blue-600 text-white rounded-lg">
    Upload {length(@uploads.images.entries)} file(s)
  </button>
</.form>

<%!-- Gallery --%>
<.media_gallery media={@streams.media} id="post-gallery">
  <:item :let={{_id, media}}>
    <.media_img media={media} conversion={:thumb} class="rounded-lg" />
  </:item>
  <:empty>
    <p>No images yet. Upload some above!</p>
  </:empty>
</.media_gallery>

Why not use <.media_upload> here? The <.media_upload> function component renders its own <.form phx-change="validate"> internally. Placing it inside another <form> creates nested forms (invalid HTML), which causes the submit to silently fail and files never transfer to the server. When building a custom UI, always use <.live_file_input> directly inside your own form.


Function Components

If you prefer the ready-made function components for parts of your UI (but don't want the full MediaLive LiveComponent), here's what's available.

Nested form warning: <.media_upload> and <.media_upload_button> each render their own <.form>. Do not place them inside another form. Use them standalone, paired with auto_upload: true on the upload config, or use the MediaLive LiveComponent instead.

<.media_upload>

Drop zone with previews, progress, and error display:

<.media_upload
  upload={@uploads.images}
  id="post-images-upload"
  label="Upload Images"
  sublabel="JPG, PNG, WebP up to 10MB"
/>

Stream-powered media grid with delete support:

<.media_gallery
  media={@streams.media}
  id="post-gallery"
  columns={4}
  conversion={:thumb}
  delete_event="delete_media"
/>

<.media_upload_button>

Compact inline upload trigger:

<.media_upload_button upload={@uploads.avatar} id="avatar-btn" label="Change photo" />

PhxMediaLibrary.LiveUpload Helpers

use PhxMediaLibrary.LiveUpload imports these functions into your LiveView:

FunctionPurpose
allow_media_upload/3Wraps allow_upload/3 with collection-aware defaults (accept types, max entries, max file size)
consume_media/5Consumes uploads and persists via PhxMediaLibrary.add/2 |> to_collection/2
stream_existing_media/4Loads existing media into a LiveView stream
stream_media_items/3Inserts newly created media into a stream
delete_media_by_id/2Deletes a media record and its files
media_upload_errors/1Human-readable error strings for an upload
media_entry_errors/2Human-readable error strings for an entry
has_upload_entries?/1Whether the upload has any entries
image_entry?/1Whether an entry is an image (for conditional previews)
translate_upload_error/1Extensible error atom → string translation

Collection-Aware Uploads

allow_media_upload/3 reads your collection definition and automatically configures the LiveView upload:

  • :accept — derived from the collection's :accepts MIME types
  • :max_entries — derived from :max_files
  • :max_file_size — derived from :max_size
# These two are equivalent when collection has accepts: ~w(image/jpeg image/png), max_files: 10, max_size: 5_000_000
allow_media_upload(socket, :images, model: post, collection: :images)

allow_upload(socket, :images,
  accept: ~w(image/jpeg image/png),
  max_entries: 10,
  max_file_size: 5_000_000
)

Event Notifications

Both consume_media/5 and delete_media_by_id/2 accept a :notify option. When set to a pid (e.g. self()), lifecycle messages are sent to that process:

  • {:media_added, [Media.t()]} — after successful upload
  • {:media_error, reason} — when upload fails
  • {:media_removed, Media.t()} — after successful deletion

Handle them in your LiveView via handle_info/2:

def handle_info({:media_added, media_items}, socket) do
  {:noreply, put_flash(socket, :info, "#{length(media_items)} file(s) uploaded")}
end

def handle_info({:media_error, reason}, socket) do
  {:noreply, put_flash(socket, :error, "Upload failed: #{inspect(reason)}")}
end

def handle_info({:media_removed, media}, socket) do
  {:noreply, put_flash(socket, :info, "Removed #{media.file_name}")}
end

View Helpers

For rendering media in templates (both LiveView and standard views), PhxMediaLibrary provides rendering components.

Simple Image

<.media_img media={@media} class="rounded-lg" />

<.media_img media={@media} conversion={:thumb} alt="Product image" />

Note: Only pass a conversion when you know the conversion has been generated. If the conversion file doesn't exist yet, the URL will 404. Pass conversion={nil} (or omit it) to use the original file, which always exists.

Responsive Image

<.responsive_img
  media={@media}
  sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 800px"
  class="w-full h-auto"
  alt="Hero image"
/>

Picture Element (Art Direction)

<.picture
  media={@media}
  sources={[
    %{media: "(max-width: 768px)", conversion: :mobile},
    %{media: "(min-width: 769px)", conversion: :desktop}
  ]}
  alt="Responsive artwork"
/>