LiveSvelte provides useLiveUpload for integrating Phoenix LiveView's file upload system with Svelte components.

Quick Example

LiveView:

defmodule MyAppWeb.UploadLive do
  use MyAppWeb, :live_view

  def mount(_params, _session, socket) do
    {:ok,
     socket
     |> assign(:uploaded_files, [])
     |> allow_upload(:avatar, accept: ~w(.jpg .png), max_entries: 1)}
  end

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

  def handle_event("submit", _params, socket) do
    uploaded_files =
      consume_uploaded_entries(socket, :avatar, fn %{path: path}, _entry ->
        dest = Path.join("priv/static/uploads", Path.basename(path))
        File.cp!(path, dest)
        {:ok, "/uploads/#{Path.basename(dest)}"}
      end)

    {:noreply,
     socket
     |> update(:uploaded_files, &(&1 ++ uploaded_files))
     |> put_flash(:info, "Uploaded successfully!")}
  end

  def render(assigns) do
    ~H"""
    <.svelte
      name="AvatarUpload"
      props={%{uploads: @uploads}}
      socket={@socket}
    />
    """
  end
end

Svelte Component:

<!-- assets/svelte/AvatarUpload.svelte -->
<script>
  import { useLiveUpload } from "live_svelte"

  let { uploads } = $props()

  const {
    showFilePicker,
    entries,
    submit,
    cancel,
    clear,
    sync
  } = useLiveUpload(uploads.avatar, { changeEvent: "validate", submitEvent: "submit" })

  // Keep the composable in sync when the server pushes updated upload config
  $effect(() => sync(uploads.avatar))
</script>

<div
  role="button"
  tabindex="0"
  onclick={showFilePicker}
  onkeydown={(e) => e.key === "Enter" && showFilePicker()}
>
  Click to select a file (or drag and drop)
</div>

{#each $entries as entry (entry.ref)}
  <div>
    <p>{entry.client_name}</p>

    <!-- Progress bar -->
    <progress value={entry.progress} max="100">{entry.progress}%</progress>

    <!-- Validation errors -->
    {#each entry.errors as error}
      <p class="error">{error}</p>
    {/each}

    <button type="button" onclick={() => cancel(entry.ref)}>Remove</button>
  </div>
{/each}

<button onclick={submit} disabled={$entries.length === 0}>Upload</button>

The useLiveUpload Composable

import { useLiveUpload } from "live_svelte"

const { showFilePicker, entries, submit, cancel, clear, sync } = useLiveUpload(
  uploads.avatar,
  { changeEvent: "validate", submitEvent: "submit" }
)

// Sync updated config from server on every render
$effect(() => sync(uploads.avatar))

The first argument is the upload config object for a specific upload field (e.g., uploads.avatar). Pass it directly — not as a getter function.

Call sync(uploads.avatar) in a $effect to keep the composable up-to-date whenever the server sends an updated config.

useLiveUpload creates a hidden <form> and <input type="file"> internally and appends them to the LiveView element. You do not need to add a form in your Svelte template.

Options

interface UploadOptions {
  changeEvent?: string  // Server event for phx-change (validation). Optional.
  submitEvent: string   // Server event for phx-submit. REQUIRED.
}

Return Values

ValueTypeDescription
showFilePicker()() => voidOpens the native file picker dialog
addFiles(files)(files: File[] | DataTransfer) => voidEnqueue files programmatically (for drag-drop)
entriesReadable<UploadEntry[]>Reactive store of current upload entries. Use $entries in templates.
progressReadable<number>Overall upload progress 0–100 averaged across all entries
validReadable<boolean>true when the upload config has no top-level errors
submit()() => voidDispatch a form submit event to trigger Phoenix upload
cancel(ref?)(ref?: string) => voidCancel entry by ref string, or all entries when called with no arg
clear()() => voidReset the hidden input to clear the file queue
sync(config)(config: UploadConfig) => voidMerge updated config from server. Call in $effect.

Upload Entry Fields

Each entry in entries has:

FieldTypeDescription
refstringUnique entry identifier
client_namestringOriginal filename
client_sizenumberFile size in bytes
client_typestringMIME type
progressnumberUpload progress (0–100)
errorsstring[]Validation error messages
validbooleanWhether entry passes validation
donebooleanWhether upload is complete
preflightedbooleanWhether Phoenix has acknowledged (preflighted) this entry

Drag and Drop

<script>
  import { useLiveUpload } from "live_svelte"

  let { uploads } = $props()
  const { entries, cancel, sync } = useLiveUpload(uploads.avatar, { submitEvent: "submit" })
  $effect(() => sync(uploads.avatar))
  let dragOver = $state(false)
</script>

<div
  class={dragOver ? "drag-over" : ""}
  ondragover={(e) => { e.preventDefault(); dragOver = true }}
  ondragleave={() => { dragOver = false }}
  ondrop={(e) => {
    e.preventDefault()
    dragOver = false
    // Phoenix LiveView handles the drop via phx-drop-target
  }}
  phx-drop-target={uploads.avatar?.ref}
>
  Drop files here
</div>

Multiple Files

Configure max_entries on the LiveView side:

allow_upload(:photos, accept: ~w(.jpg .png .gif), max_entries: 5)

The entries array in the component will reflect all selected files.

Validation

File validation is configured with allow_upload/3 options:

allow_upload(:avatar,
  accept: ~w(.jpg .png .webp),
  max_entries: 1,
  max_file_size: 10_000_000  # 10 MB
)

Validation errors appear in entry.errors as human-readable strings.

Progress Tracking

Upload progress is automatically tracked per entry via entry.progress (0–100):

{#each $entries as entry (entry.ref)}
  <div class="upload-item">
    <span>{entry.client_name}</span>
    <div class="progress-bar" style="width: {entry.progress}%"></div>
    {#if entry.done}
      <span>✓ Complete</span>
    {/if}
  </div>
{/each}