shshadcn-htmx

Components

File Upload

A styled <label> wrapping a native <input type="file">, with drag-and-drop, a filename/preview list, and a native <progress> bar. It submits with a standard multipart POST, so it works in a plain form and degrades to a bare picker without JavaScript.

Installation

One file per stack. Use the shadcn CLI for JSX, or copy the source for your template engine.

1. Install via the shadcn CLI

npx shadcn@latest add http://localhost/r/file-upload.json

2. Use it

components/ui/file-upload.tsx
import { FileUpload } from "@/components/ui/file-upload"

<form hx-post="/upload" hx-encoding="multipart/form-data"
      hx-target="#result" hx-swap="outerHTML">
  <FileUpload name="files" accept="image/*" multiple
              hint="PNG, JPG up to 5MB" preserve />
  <button type="submit">Upload</button>
</form>
Or copy the source manually
components/ui/file-upload.tsx
/** @jsxImportSource hono/jsx */
import { cn, type ClassValue } from "@/registry/lib/cn"

// File Upload — shadcn-htmx, htmx v4 + Tailwind v4.
//
// A styled <label>-wrapped native <input type="file"> with an optional
// drag-and-drop enhancement, a selected-file list (with image previews),
// and a native <progress> upload bar. It submits via a standard multipart
// POST — no custom transport — so it works with a plain <form> and degrades
// to a bare file picker when JavaScript is off.
//
// There is no "file upload" or "dropzone" element in the platform — the
// pieces are:
//   - <input type="file"> + <label>  — the picker + its accessible name.
//       repos/mdn/files/en-us/web/html/reference/elements/input/file/index.md
//   - Drag-and-Drop API (drop/dragover) + File API (input.files, FileList)
//     for the optional drop-zone enhancement.
//       repos/mdn/files/en-us/web/api/html_drag_and_drop_api/file_drag_and_drop/index.md
//   - htmx multipart upload (hx-encoding="multipart/form-data") and
//     hx-preserve to keep the selection across re-render-on-error swaps.
//       repos/htmx/www/src/content/patterns/02-forms/03-file-upload.md
//       repos/htmx/www/reference.md  (hx-encoding, hx-preserve)
//
// Style analogues (matched exactly): registry/ui/input.tsx (the field /
// file:* affordance + focus-visible ring + .htmx-request dim) and
// registry/ui/progress.tsx (the native <progress> visual).
//
// The keyboard/behaviour contract (drop wiring + filename/preview list +
// reset) lives in public/site.js, scoped to [data-slot="file-upload"]; an
// inline boot script next to the root only marks it ready, so a server swap
// re-arms cleanly. None of it is required for the upload to work.

const root =
  "group/file-upload grid w-full gap-3 " +
  // While a request triggered by/targeting this control is in flight,
  // htmx adds .htmx-request — dim like Input does.
  "[&.htmx-request]:opacity-70"

const zone =
  "flex cursor-pointer flex-col items-center justify-center gap-2 rounded-md border border-dashed border-input bg-transparent px-6 py-8 text-center text-sm text-muted-foreground shadow-xs transition-[color,box-shadow] outline-none " +
  "dark:bg-input/30 " +
  // The visually-hidden <input> is the real focus target; mirror its focus
  // ring onto the styled label via :focus-within.
  "focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50 " +
  "hover:border-ring/60 hover:text-foreground " +
  // site.js sets data-dragover on the label while a file is over it.
  "data-[dragover=true]:border-ring data-[dragover=true]:bg-accent data-[dragover=true]:text-foreground " +
  // Disabled mirrors Input.
  "has-[input:disabled]:pointer-events-none has-[input:disabled]:opacity-50 " +
  "aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40"

// Visually-hidden but still focusable + operable: the native picker stays
// the accessible control; the label is its visible skin.
const srOnly =
  "absolute size-px overflow-hidden border-0 p-0 whitespace-nowrap [clip:rect(0,0,0,0)]"

type FileUploadProps = {
  // Form field name — required to actually submit. multiple sends one entry
  // per file under this name.
  name?: string
  id?: string
  // Comma-separated unique file type specifiers (".pdf,image/*"). Native
  // filter in the OS picker; the drop enhancement re-checks it too.
  accept?: string
  multiple?: boolean
  required?: boolean
  disabled?: boolean
  // type="file" only — request the OS camera (user | environment).
  capture?: "user" | "environment" | boolean
  // Associate the input with a <form> by id. Per the htmx pattern, putting
  // the file input OUTSIDE the swap target (via form=) is an alternative to
  // hx-preserve for keeping the selection across error re-renders.
  form?: string
  // htmx: keep the chosen file across an outerHTML/innerHTML swap when the
  // form re-renders with validation errors. Renders the hx-preserve attr.
  preserve?: boolean

  // Visible prompt + sub-label inside the drop zone.
  label?: string
  hint?: string

  // Show the native <progress> bar. value=undefined → indeterminate
  // ("uploading…, length unknown"); a number 0–100 → determinate.
  showProgress?: boolean
  progress?: number

  class?: ClassValue
  // ARIA — the visible label text names the input by default; override here
  // when there is no visible label or a separate one elsewhere.
  ariaLabel?: string
  ariaLabelledby?: string
  ariaDescribedby?: string
  ariaInvalid?: boolean | "grammar" | "spelling"

  // htmx v4 (subset). Usually set on the wrapping <form> (hx-post +
  // hx-encoding="multipart/form-data"), but forwarded here too so a
  // standalone control can drive an upload on change.
  "hx-post"?: string
  "hx-put"?: string
  "hx-target"?: string
  "hx-swap"?: string
  "hx-trigger"?: string
  "hx-encoding"?: string
  "hx-indicator"?: string
  "hx-include"?: string
  "hx-preserve"?: boolean | "true"
  "hx-disable"?: string
}

export function FileUpload(props: FileUploadProps) {
  const {
    name,
    id,
    accept,
    multiple,
    required,
    disabled,
    capture,
    form,
    preserve,
    label = "Drop files here, or click to upload",
    hint,
    showProgress,
    progress,
    class: className,
    ariaLabel,
    ariaLabelledby,
    ariaDescribedby,
    ariaInvalid,
    ...rest
  } = props

  const determinate = progress !== undefined
  const pct = determinate ? Math.min(100, Math.max(0, progress!)) : 0

  // Boot marks the root ready so site.js arms drop + the file list once,
  // and re-arms after an htmx swap re-inserts a fresh root.
  const boot = `(function(el){el.setAttribute('data-file-upload-ready','true');})(document.currentScript.previousElementSibling);`

  return (
    <>
      <div
        id={id}
        data-slot="file-upload"
        class={cn(root, className)}
      >
        <label
          data-slot="file-upload-zone"
          aria-invalid={ariaInvalid === undefined ? undefined : String(ariaInvalid)}
          class={zone}
        >
          <svg
            xmlns="http://www.w3.org/2000/svg"
            viewBox="0 0 24 24"
            fill="none"
            stroke="currentColor"
            stroke-width="2"
            stroke-linecap="round"
            stroke-linejoin="round"
            class="size-6 shrink-0 opacity-70"
            aria-hidden="true"
          >
            <path d="M12 13v8" />
            <path d="m8 17 4-4 4 4" />
            <path d="M20 16.7A5 5 0 0 0 18 7h-1.26A8 8 0 1 0 4 15.25" />
          </svg>
          <span class="font-medium text-foreground">{label}</span>
          {hint && <span class="text-xs">{hint}</span>}
          <input
            type="file"
            data-slot="file-upload-input"
            class={srOnly}
            name={name}
            accept={accept}
            multiple={multiple}
            required={required}
            disabled={disabled}
            capture={capture}
            form={form}
            hx-preserve={preserve ? "true" : undefined}
            aria-label={ariaLabel ?? (ariaLabelledby ? undefined : label)}
            aria-labelledby={ariaLabelledby}
            aria-describedby={ariaDescribedby}
            aria-invalid={ariaInvalid === undefined ? undefined : String(ariaInvalid)}
            {...rest}
          />
        </label>

        <ul
          data-slot="file-upload-list"
          class="m-0 grid list-none gap-2 p-0 empty:hidden"
          aria-live="polite"
        />

        {showProgress && (
          <div
            data-slot="file-upload-progress"
            role="progressbar"
            aria-label="Upload progress"
            aria-valuemin={0}
            aria-valuemax={100}
            aria-valuenow={determinate ? pct : undefined}
            data-state={determinate ? "determinate" : "indeterminate"}
            class="relative h-2 w-full overflow-hidden rounded-full bg-primary/20"
          >
            <div
              data-slot="file-upload-progress-indicator"
              class={cn(
                "h-full bg-primary transition-all",
                !determinate &&
                  "absolute inset-y-0 -left-1/3 w-1/3 animate-[scn-progress-indeterminate_1.2s_ease-in-out_infinite]",
              )}
              style={determinate ? `width: ${pct}%` : undefined}
            />
          </div>
        )}
      </div>
      <script
        // biome-ignore lint/security/noDangerouslySetInnerHtml: SSR boot
        dangerouslySetInnerHTML={{ __html: boot }}
      />
    </>
  )
}

1. Save the file

Copy file-upload.html into templates/components/.

2. Use it

templates/components/file-upload.html
{% from "components/file-upload.html" import file_upload %}

<form hx-post="/upload" hx-encoding="multipart/form-data"
      hx-target="#result" hx-swap="outerHTML">
  {{ file_upload(name="files", accept="image/*", multiple=true,
                 hint="PNG, JPG up to 5MB", preserve=true) }}
  <button type="submit">Upload</button>
</form>
View source
templates/components/file-upload.html
{# File Upload macro — shadcn-htmx, htmx v4 + Tailwind v4.
   Mirrors registry/ui/file-upload.tsx for Python/Flask/FastAPI/Django/Jinja2.

   A <label>-wrapped native <input type="file"> with a drag-and-drop
   enhancement, a selected-file list, and a native <progress> bar. Submits
   via a standard multipart POST.

   Usage:
       {% from "components/file-upload.html" import file_upload %}

       <form hx-post="/upload" hx-encoding="multipart/form-data"
             hx-target="#result" hx-swap="outerHTML">
         {{ file_upload(name="files", accept="image/*", multiple=true,
                        hint="PNG, JPG up to 5MB", show_progress=true,
                        preserve=true) }}
         <button type="submit">Upload</button>
       </form>

   Sources (see header of file-upload.tsx for the full citations):
     repos/mdn/.../elements/input/file
     repos/mdn/.../api/html_drag_and_drop_api/file_drag_and_drop
     repos/htmx/.../patterns/02-forms/03-file-upload (hx-encoding, hx-preserve)

   All hx-* / data-* / aria-* pass through via **attrs (underscores become
   dashes, so `hx_post="/upload"` emits `hx-post="/upload"`). #}

{% macro file_upload(
    name=none,
    id=none,
    accept=none,
    multiple=false,
    required=false,
    disabled=false,
    capture=none,
    form=none,
    preserve=false,
    label="Drop files here, or click to upload",
    hint=none,
    show_progress=false,
    progress=none,
    aria_label=none,
    aria_labelledby=none,
    aria_describedby=none,
    aria_invalid=none,
    extra_class="",
    **attrs
) %}
{%- set root -%}
group/file-upload grid w-full gap-3 [&.htmx-request]:opacity-70
{%- endset -%}
{%- set zone -%}
flex cursor-pointer flex-col items-center justify-center gap-2 rounded-md border border-dashed border-input bg-transparent px-6 py-8 text-center text-sm text-muted-foreground shadow-xs transition-[color,box-shadow] outline-none dark:bg-input/30 focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50 hover:border-ring/60 hover:text-foreground data-[dragover=true]:border-ring data-[dragover=true]:bg-accent data-[dragover=true]:text-foreground has-[input:disabled]:pointer-events-none has-[input:disabled]:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40
{%- endset -%}
{%- set sr_only -%}
absolute size-px overflow-hidden border-0 p-0 whitespace-nowrap [clip:rect(0,0,0,0)]
{%- endset -%}
{%- set determinate = progress is not none -%}
{%- set pct = ([progress|float, 0]|max) if determinate else 0 -%}
{%- set pct = [pct, 100]|min if determinate else 0 -%}
<div data-slot="file-upload" class="{{ root }} {{ extra_class }}"
     {%- if id %} id="{{ id }}"{% endif %}>
  <label data-slot="file-upload-zone"
         {%- if aria_invalid is not none %} aria-invalid="{{ aria_invalid|string|lower }}"{% endif %}
         class="{{ zone }}">
    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-6 shrink-0 opacity-70" aria-hidden="true">
      <path d="M12 13v8" />
      <path d="m8 17 4-4 4 4" />
      <path d="M20 16.7A5 5 0 0 0 18 7h-1.26A8 8 0 1 0 4 15.25" />
    </svg>
    <span class="font-medium text-foreground">{{ label }}</span>
    {%- if hint %}<span class="text-xs">{{ hint }}</span>{% endif %}
    <input type="file" data-slot="file-upload-input" class="{{ sr_only }}"
           {%- if name %} name="{{ name }}"{% endif %}
           {%- if accept %} accept="{{ accept }}"{% endif %}
           {%- if multiple %} multiple{% endif %}
           {%- if required %} required{% endif %}
           {%- if disabled %} disabled{% endif %}
           {%- if capture is not none %} capture{% if capture is string %}="{{ capture }}"{% endif %}{% endif %}
           {%- if form %} form="{{ form }}"{% endif %}
           {%- if preserve %} hx-preserve="true"{% endif %}
           {%- if aria_label %} aria-label="{{ aria_label }}"{% elif not aria_labelledby %} aria-label="{{ label }}"{% endif %}
           {%- if aria_labelledby %} aria-labelledby="{{ aria_labelledby }}"{% endif %}
           {%- if aria_describedby %} aria-describedby="{{ aria_describedby }}"{% endif %}
           {%- if aria_invalid is not none %} aria-invalid="{{ aria_invalid|string|lower }}"{% endif %}
           {%- for k, v in attrs.items() %} {{ k|replace('_', '-') }}="{{ v }}"{% endfor -%}
    >
  </label>

  <ul data-slot="file-upload-list" class="m-0 grid list-none gap-2 p-0 empty:hidden" aria-live="polite"></ul>

  {%- if show_progress %}
  <div data-slot="file-upload-progress" role="progressbar" aria-label="Upload progress"
       aria-valuemin="0" aria-valuemax="100"
       {%- if determinate %} aria-valuenow="{{ pct }}"{% endif %}
       data-state="{{ 'determinate' if determinate else 'indeterminate' }}"
       class="relative h-2 w-full overflow-hidden rounded-full bg-primary/20">
    <div data-slot="file-upload-progress-indicator"
         class="h-full bg-primary transition-all{% if not determinate %} absolute inset-y-0 -left-1/3 w-1/3 animate-[scn-progress-indeterminate_1.2s_ease-in-out_infinite]{% endif %}"
         {%- if determinate %} style="width: {{ pct }}%"{% endif %}></div>
  </div>
  {%- endif %}
</div>
<script>(function(el){el.setAttribute('data-file-upload-ready','true');})(document.currentScript.previousElementSibling);</script>
{% endmacro %}

1. Save the file

Add file-upload.tmpl alongside your templates.

2. Use it

components/file-upload.tmpl
tpl.ExecuteTemplate(w, "file-upload", map[string]any{
    "Name": "files",
    "Accept": "image/*",
    "Multiple": true,
    "Hint": "PNG, JPG up to 5MB",
    "Preserve": true,
})
View source
components/file-upload.tmpl
{{/*
  File Upload template — shadcn-htmx, htmx v4 + Tailwind v4.
  Mirrors registry/ui/file-upload.tsx for Go projects using html/template.

  A <label>-wrapped native <input type="file"> with a drag-and-drop
  enhancement, a selected-file list, and a native <progress> bar. Submits via
  a standard multipart POST.

  Usage in your code:

      type FileUploadArgs struct {
          Name        string
          ID          string
          Accept      string // ".pdf,image/*"
          Multiple    bool
          Required    bool
          Disabled    bool
          Capture     string // "" | "user" | "environment" | "true"
          Form        string // associate with a <form> by id
          Preserve    bool   // emit hx-preserve="true"
          Label       string // visible prompt (default below)
          Hint        string // sub-label
          ShowProgress bool
          Progress    string // "" = indeterminate, "0".."100" = determinate

          AriaLabel       string
          AriaLabelledby  string
          AriaDescribedby string
          AriaInvalid     string // "true" | "false" | "grammar" | "spelling"

          // Everything else (hx-post, hx-encoding, hx-target, …)
          Attrs map[string]string
      }

      tpl.ExecuteTemplate(w, "file-upload", FileUploadArgs{
          Name: "files", Accept: "image/*", Multiple: true,
          Hint: "PNG, JPG up to 5MB", ShowProgress: true, Preserve: true,
      })

  Sources: repos/mdn/.../elements/input/file,
  repos/mdn/.../api/html_drag_and_drop_api/file_drag_and_drop,
  repos/htmx/.../patterns/02-forms/03-file-upload (hx-encoding, hx-preserve).
*/}}

{{define "file-upload"}}
{{- $label := or .Label "Drop files here, or click to upload" -}}
{{- $determinate := ne .Progress "" -}}
{{- $root := "group/file-upload grid w-full gap-3 [&.htmx-request]:opacity-70" -}}
{{- $zone := "flex cursor-pointer flex-col items-center justify-center gap-2 rounded-md border border-dashed border-input bg-transparent px-6 py-8 text-center text-sm text-muted-foreground shadow-xs transition-[color,box-shadow] outline-none dark:bg-input/30 focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50 hover:border-ring/60 hover:text-foreground data-[dragover=true]:border-ring data-[dragover=true]:bg-accent data-[dragover=true]:text-foreground has-[input:disabled]:pointer-events-none has-[input:disabled]:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40" -}}
{{- $srOnly := "absolute size-px overflow-hidden border-0 p-0 whitespace-nowrap [clip:rect(0,0,0,0)]" -}}
<div data-slot="file-upload" class="{{$root}}"
     {{- if .ID}} id="{{.ID}}"{{end}}>
  <label data-slot="file-upload-zone"
         {{- if .AriaInvalid}} aria-invalid="{{.AriaInvalid}}"{{end}}
         class="{{$zone}}">
    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-6 shrink-0 opacity-70" aria-hidden="true">
      <path d="M12 13v8" />
      <path d="m8 17 4-4 4 4" />
      <path d="M20 16.7A5 5 0 0 0 18 7h-1.26A8 8 0 1 0 4 15.25" />
    </svg>
    <span class="font-medium text-foreground">{{$label}}</span>
    {{- if .Hint}}<span class="text-xs">{{.Hint}}</span>{{end}}
    <input type="file" data-slot="file-upload-input" class="{{$srOnly}}"
           {{- if .Name}} name="{{.Name}}"{{end}}
           {{- if .Accept}} accept="{{.Accept}}"{{end}}
           {{- if .Multiple}} multiple{{end}}
           {{- if .Required}} required{{end}}
           {{- if .Disabled}} disabled{{end}}
           {{- if .Capture}} capture{{if ne .Capture "true"}}="{{.Capture}}"{{end}}{{end}}
           {{- if .Form}} form="{{.Form}}"{{end}}
           {{- if .Preserve}} hx-preserve="true"{{end}}
           {{- if .AriaLabel}} aria-label="{{.AriaLabel}}"{{else if not .AriaLabelledby}} aria-label="{{$label}}"{{end}}
           {{- if .AriaLabelledby}} aria-labelledby="{{.AriaLabelledby}}"{{end}}
           {{- if .AriaDescribedby}} aria-describedby="{{.AriaDescribedby}}"{{end}}
           {{- if .AriaInvalid}} aria-invalid="{{.AriaInvalid}}"{{end}}
           {{- range $k, $v := .Attrs}} {{$k}}="{{$v}}"{{end -}}
    >
  </label>

  <ul data-slot="file-upload-list" class="m-0 grid list-none gap-2 p-0 empty:hidden" aria-live="polite"></ul>

  {{- if .ShowProgress}}
  <div data-slot="file-upload-progress" role="progressbar" aria-label="Upload progress"
       aria-valuemin="0" aria-valuemax="100"
       {{- if $determinate}} aria-valuenow="{{.Progress}}"{{end}}
       data-state="{{if $determinate}}determinate{{else}}indeterminate{{end}}"
       class="relative h-2 w-full overflow-hidden rounded-full bg-primary/20">
    <div data-slot="file-upload-progress-indicator"
         class="h-full bg-primary transition-all{{if not $determinate}} absolute inset-y-0 -left-1/3 w-1/3 animate-[scn-progress-indeterminate_1.2s_ease-in-out_infinite]{{end}}"
         {{- if $determinate}} style="width: {{.Progress}}%"{{end}}></div>
  </div>
  {{- end}}
</div>
<script>(function(el){el.setAttribute('data-file-upload-ready','true');})(document.currentScript.previousElementSibling);</script>
{{end}}

1. Save the file

Drop file_upload.ex into lib/my_app_web/components/.

2. Use it

lib/my_app_web/components/file_upload.ex
<form hx-post="/upload" hx-encoding="multipart/form-data"
      hx-target="#result" hx-swap="outerHTML">
  <.file_upload name="files" accept="image/*" multiple
    hint="PNG, JPG up to 5MB" preserve />
  <button type="submit">Upload</button>
</form>
View source
lib/my_app_web/components/file_upload.ex
defmodule ShadcnHtmx.Components.FileUpload do
  @moduledoc """
  File Upload — shadcn-htmx, htmx v4 + Tailwind v4 for Phoenix.

  Mirrors registry/ui/file-upload.tsx. A `<label>`-wrapped native
  `<input type="file">` with a drag-and-drop enhancement, a selected-file
  list, and a native `<progress>` bar. Submits via a standard multipart POST.

  The drop wiring + filename/preview list live in public/site.js, scoped to
  `[data-slot="file-upload"]`. An inline boot script marks the root ready so
  a swap re-arms cleanly. None of it is required for the upload to work.

  ## Examples

      <form hx-post="/upload" hx-encoding="multipart/form-data"
            hx-target="#result" hx-swap="outerHTML">
        <.file_upload name="files" accept="image/*" multiple
          hint="PNG, JPG up to 5MB" show_progress preserve />
        <button type="submit">Upload</button>
      </form>

  Sources: repos/mdn/.../elements/input/file,
  repos/mdn/.../api/html_drag_and_drop_api/file_drag_and_drop,
  repos/htmx/.../patterns/02-forms/03-file-upload (hx-encoding, hx-preserve).
  """

  use Phoenix.Component

  @root "group/file-upload grid w-full gap-3 [&.htmx-request]:opacity-70"

  @zone "flex cursor-pointer flex-col items-center justify-center gap-2 rounded-md border border-dashed border-input " <>
          "bg-transparent px-6 py-8 text-center text-sm text-muted-foreground shadow-xs transition-[color,box-shadow] outline-none " <>
          "dark:bg-input/30 " <>
          "focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50 " <>
          "hover:border-ring/60 hover:text-foreground " <>
          "data-[dragover=true]:border-ring data-[dragover=true]:bg-accent data-[dragover=true]:text-foreground " <>
          "has-[input:disabled]:pointer-events-none has-[input:disabled]:opacity-50 " <>
          "aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40"

  @sr_only "absolute size-px overflow-hidden border-0 p-0 whitespace-nowrap [clip:rect(0,0,0,0)]"

  attr :name, :string, default: nil
  attr :id, :string, default: nil
  attr :accept, :string, default: nil
  attr :multiple, :boolean, default: false
  attr :required, :boolean, default: false
  attr :disabled, :boolean, default: false
  attr :capture, :string, default: nil
  attr :form, :string, default: nil
  attr :preserve, :boolean, default: false
  attr :label, :string, default: "Drop files here, or click to upload"
  attr :hint, :string, default: nil
  attr :show_progress, :boolean, default: false
  attr :progress, :integer, default: nil
  attr :class, :string, default: nil

  attr :rest, :global,
    include:
      ~w(hx-post hx-put hx-target hx-swap hx-trigger hx-encoding hx-indicator hx-include hx-disable
         aria-label aria-labelledby aria-describedby aria-invalid)

  def file_upload(assigns) do
    determinate = assigns.progress != nil
    pct = if determinate, do: assigns.progress |> max(0) |> min(100), else: 0

    assigns =
      assigns
      |> assign(:root_class, @root)
      |> assign(:zone_class, @zone)
      |> assign(:sr_only_class, @sr_only)
      |> assign(:determinate, determinate)
      |> assign(:pct, pct)

    ~H"""
    <div id={@id} data-slot="file-upload" class={[@root_class, @class]}>
      <label data-slot="file-upload-zone" class={@zone_class}>
        <svg
          xmlns="http://www.w3.org/2000/svg"
          viewBox="0 0 24 24"
          fill="none"
          stroke="currentColor"
          stroke-width="2"
          stroke-linecap="round"
          stroke-linejoin="round"
          class="size-6 shrink-0 opacity-70"
          aria-hidden="true"
        >
          <path d="M12 13v8" />
          <path d="m8 17 4-4 4 4" />
          <path d="M20 16.7A5 5 0 0 0 18 7h-1.26A8 8 0 1 0 4 15.25" />
        </svg>
        <span class="font-medium text-foreground">{@label}</span>
        <span :if={@hint} class="text-xs">{@hint}</span>
        <input
          type="file"
          data-slot="file-upload-input"
          class={@sr_only_class}
          name={@name}
          accept={@accept}
          multiple={@multiple}
          required={@required}
          disabled={@disabled}
          capture={@capture}
          form={@form}
          hx-preserve={if @preserve, do: "true"}
          aria-label={@label}
          {@rest}
        />
      </label>

      <ul
        data-slot="file-upload-list"
        class="m-0 grid list-none gap-2 p-0 empty:hidden"
        aria-live="polite"
      >
      </ul>

      <div
        :if={@show_progress}
        data-slot="file-upload-progress"
        role="progressbar"
        aria-label="Upload progress"
        aria-valuemin="0"
        aria-valuemax="100"
        aria-valuenow={if @determinate, do: @pct}
        data-state={if @determinate, do: "determinate", else: "indeterminate"}
        class="relative h-2 w-full overflow-hidden rounded-full bg-primary/20"
      >
        <div
          data-slot="file-upload-progress-indicator"
          class={[
            "h-full bg-primary transition-all",
            !@determinate &&
              "absolute inset-y-0 -left-1/3 w-1/3 animate-[scn-progress-indeterminate_1.2s_ease-in-out_infinite]"
          ]}
          style={if @determinate, do: "width: #{@pct}%"}
        >
        </div>
      </div>
    </div>
    <script>{Phoenix.HTML.raw(~s"""
      (function(el){el.setAttribute('data-file-upload-ready','true');})(document.currentScript.previousElementSibling);
    """)}</script>
    """
  end
end

1. Save the file

Paste the markup; relies only on theme tokens.

2. Use it

snippets/file-upload.html
<form hx-post="/upload" hx-encoding="multipart/form-data"
      hx-target="#result" hx-swap="outerHTML">
  <div data-slot="file-upload" class="group/file-upload grid w-full gap-3 …">
    <label data-slot="file-upload-zone" class="… border-dashed …">
      <span class="font-medium text-foreground">Drop files here, or click to upload</span>
      <input type="file" data-slot="file-upload-input" name="files"
             accept="image/*" multiple hx-preserve="true"
             class="absolute size-px … [clip:rect(0,0,0,0)]">
    </label>
    <ul data-slot="file-upload-list" class="… empty:hidden" aria-live="polite"></ul>
  </div>
  <button type="submit">Upload</button>
</form>
View source
snippets/file-upload.html
<!--
  shadcn-htmx — raw HTML file-upload snippet.

  Mirrors registry/ui/file-upload.tsx. Drop onto any page that loads Tailwind
  CSS v4 + the shadcn theme variables (input, ring, accent, foreground,
  muted-foreground, primary) and htmx. See app/styles/input.css for the
  variable defaults and the scn-progress-indeterminate keyframes.

  A <label>-wrapped native <input type="file"> with a drag-and-drop
  enhancement, a selected-file list, and a native <progress> bar. Submits via
  a standard multipart POST — wrap it in a <form> with:

      <form hx-post="/upload" hx-encoding="multipart/form-data"
            hx-target="#result" hx-swap="outerHTML"> … </form>

  The drop wiring + filename/preview list come from public/site.js
  ([data-slot="file-upload"]); the inline boot <script> just marks the root
  ready so a swap re-arms. Without JS this is a plain file picker that still
  uploads.

  Sources: repos/mdn/.../elements/input/file,
  repos/mdn/.../api/html_drag_and_drop_api/file_drag_and_drop,
  repos/htmx/.../patterns/02-forms/03-file-upload (hx-encoding, hx-preserve).
-->

<form hx-post="/upload" hx-encoding="multipart/form-data" hx-target="#fu-result" hx-swap="outerHTML">
  <div data-slot="file-upload" class="group/file-upload grid w-full gap-3 [&.htmx-request]:opacity-70">
    <label data-slot="file-upload-zone"
      class="flex cursor-pointer flex-col items-center justify-center gap-2 rounded-md border border-dashed border-input bg-transparent px-6 py-8 text-center text-sm text-muted-foreground shadow-xs transition-[color,box-shadow] outline-none dark:bg-input/30 focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50 hover:border-ring/60 hover:text-foreground data-[dragover=true]:border-ring data-[dragover=true]:bg-accent data-[dragover=true]:text-foreground has-[input:disabled]:pointer-events-none has-[input:disabled]:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40">
      <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-6 shrink-0 opacity-70" aria-hidden="true">
        <path d="M12 13v8" />
        <path d="m8 17 4-4 4 4" />
        <path d="M20 16.7A5 5 0 0 0 18 7h-1.26A8 8 0 1 0 4 15.25" />
      </svg>
      <span class="font-medium text-foreground">Drop files here, or click to upload</span>
      <span class="text-xs">PNG, JPG up to 5MB</span>
      <input type="file" data-slot="file-upload-input"
        class="absolute size-px overflow-hidden border-0 p-0 whitespace-nowrap [clip:rect(0,0,0,0)]"
        name="files" accept="image/*" multiple hx-preserve="true"
        aria-label="Drop files here, or click to upload">
    </label>

    <ul data-slot="file-upload-list" class="m-0 grid list-none gap-2 p-0 empty:hidden" aria-live="polite"></ul>

    <div data-slot="file-upload-progress" role="progressbar" aria-label="Upload progress"
      aria-valuemin="0" aria-valuemax="100" data-state="indeterminate"
      class="relative h-2 w-full overflow-hidden rounded-full bg-primary/20">
      <div data-slot="file-upload-progress-indicator"
        class="h-full bg-primary transition-all absolute inset-y-0 -left-1/3 w-1/3 animate-[scn-progress-indeterminate_1.2s_ease-in-out_infinite]"></div>
    </div>
  </div>
  <script>(function(el){el.setAttribute('data-file-upload-ready','true');})(document.currentScript.previousElementSibling);</script>

  <button type="submit" class="mt-3 inline-flex h-9 items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90">Upload</button>
</form>

Examples

Basic — label-wrapped file input

A single styled drop zone. The visible <label> wraps a visually-hidden native <input type="file">, so clicking, focus, and the OS picker all come from the platform.

The browser already pairs a <label> with the <input type="file"> it contains — clicking anywhere on the zone opens the OS file picker, and the input stays the real, focusable, accessible control. We only hide the default file button and restyle the label. The accept attribute pre-filters the picker; pick the right one rather than validating type in JavaScript.

    <FileUpload name="document" accept=".pdf,.doc,.docx"
                hint="PDF or Word, up to 10MB" />
    {{ file_upload(name="document", accept=".pdf,.doc,.docx",
                   hint="PDF or Word, up to 10MB") }}
    {{template "file-upload" (dict
      "Name" "document" "Accept" ".pdf,.doc,.docx"
      "Hint" "PDF or Word, up to 10MB")}}
    <.file_upload name="document" accept=".pdf,.doc,.docx"
      hint="PDF or Word, up to 10MB" />
    <div class="w-full max-w-md p-6">
      <div id="ex-basic-fu" data-slot="file-upload" class="group/file-upload grid w-full gap-3 [&amp;.htmx-request]:opacity-70">
        <label data-slot="file-upload-zone" class="flex cursor-pointer flex-col items-center justify-center gap-2 rounded-md border border-dashed border-input bg-transparent px-6 py-8 text-center text-sm text-muted-foreground shadow-xs transition-[color,box-shadow] outline-none dark:bg-input/30 focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50 hover:border-ring/60 hover:text-foreground data-[dragover=true]:border-ring data-[dragover=true]:bg-accent data-[dragover=true]:text-foreground has-[input:disabled]:pointer-events-none has-[input:disabled]:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40">
          <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-6 shrink-0 opacity-70" aria-hidden="true">
            <path d="M12 13v8">
            </path>
            <path d="m8 17 4-4 4 4">
            </path>
            <path d="M20 16.7A5 5 0 0 0 18 7h-1.26A8 8 0 1 0 4 15.25">
            </path>
          </svg>
          <span class="font-medium text-foreground">Drop files here, or click to upload</span>
          <span class="text-xs">PDF or Word, up to 10MB</span>
          <input type="file" data-slot="file-upload-input" class="absolute size-px overflow-hidden border-0 p-0 whitespace-nowrap [clip:rect(0,0,0,0)]" name="document" accept=".pdf,.doc,.docx" aria-label="Drop files here, or click to upload"/>
        </label>
        <ul data-slot="file-upload-list" class="m-0 grid list-none gap-2 p-0 empty:hidden" aria-live="polite">
        </ul>
      </div>
      <script>
        (function(el){el.setAttribute('data-file-upload-ready','true');})(document.currentScript.previousElementSibling);
      </script>
    </div>

    Multiple files + previews

    multiple lets the user pick or drop several files. site.js lists each selected file (with an image thumbnail when it's a picture) under the zone.

    With multiple the native picker returns a FileList; the drop enhancement reads the same input.files. The list and URL.createObjectURL thumbnails are pure progressive enhancement — drop is wired on the label per the MDN File drag-and-drop guide, and the same change handler powers both the picker and the drop.

      <FileUpload name="photos" accept="image/*" multiple
                  hint="Drop images or click — PNG, JPG, GIF" />
      {{ file_upload(name="photos", accept="image/*", multiple=true,
                     hint="Drop images or click — PNG, JPG, GIF") }}
      {{template "file-upload" (dict
        "Name" "photos" "Accept" "image/*" "Multiple" true
        "Hint" "Drop images or click — PNG, JPG, GIF")}}
      <.file_upload name="photos" accept="image/*" multiple
        hint="Drop images or click — PNG, JPG, GIF" />
      <div class="w-full max-w-md p-6">
        <div id="ex-multiple-fu" data-slot="file-upload" class="group/file-upload grid w-full gap-3 [&amp;.htmx-request]:opacity-70">
          <label data-slot="file-upload-zone" class="flex cursor-pointer flex-col items-center justify-center gap-2 rounded-md border border-dashed border-input bg-transparent px-6 py-8 text-center text-sm text-muted-foreground shadow-xs transition-[color,box-shadow] outline-none dark:bg-input/30 focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50 hover:border-ring/60 hover:text-foreground data-[dragover=true]:border-ring data-[dragover=true]:bg-accent data-[dragover=true]:text-foreground has-[input:disabled]:pointer-events-none has-[input:disabled]:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40">
            <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-6 shrink-0 opacity-70" aria-hidden="true">
              <path d="M12 13v8">
              </path>
              <path d="m8 17 4-4 4 4">
              </path>
              <path d="M20 16.7A5 5 0 0 0 18 7h-1.26A8 8 0 1 0 4 15.25">
              </path>
            </svg>
            <span class="font-medium text-foreground">Drop files here, or click to upload</span>
            <span class="text-xs">Drop images or click — PNG, JPG, GIF</span>
            <input type="file" data-slot="file-upload-input" class="absolute size-px overflow-hidden border-0 p-0 whitespace-nowrap [clip:rect(0,0,0,0)]" name="photos" accept="image/*" multiple="" aria-label="Drop files here, or click to upload"/>
          </label>
          <ul data-slot="file-upload-list" class="m-0 grid list-none gap-2 p-0 empty:hidden" aria-live="polite">
          </ul>
        </div>
        <script>
          (function(el){el.setAttribute('data-file-upload-ready','true');})(document.currentScript.previousElementSibling);
        </script>
      </div>

      htmx — multipart upload

      Wrap it in a form with hx-encoding="multipart/form-data" and hx-post. htmx sends the files as FormData; the server returns HTML that swaps in. hx-preserve keeps the selection if the form re-renders with errors.

      This is the standard htmx file pattern: hx-encoding="multipart/form-data" is what turns the request into a FormData POST — required for files. The server owns validation and responds with HTML; hx-preserve on the input keeps the chosen file across an error re-render, since file inputs otherwise lose their selection on swap. Pick a file below and submit.

        No file uploaded yet.

        <form hx-post="/upload" hx-encoding="multipart/form-data"
              hx-target="#result" hx-swap="innerHTML">
          <FileUpload name="file" accept="image/*,.pdf" preserve />
          <button type="submit">Upload</button>
          <p id="result" aria-live="polite"></p>
        </form>
        <form hx-post="/upload" hx-encoding="multipart/form-data"
              hx-target="#result" hx-swap="innerHTML">
          {{ file_upload(name="file", accept="image/*,.pdf", preserve=true) }}
          <button type="submit">Upload</button>
          <p id="result" aria-live="polite"></p>
        </form>
        <form hx-post="/upload" hx-encoding="multipart/form-data"
              hx-target="#result" hx-swap="innerHTML">
          {{template "file-upload" (dict "Name" "file" "Accept" "image/*,.pdf" "Preserve" true)}}
          <button type="submit">Upload</button>
          <p id="result" aria-live="polite"></p>
        </form>
        <form hx-post="/upload" hx-encoding="multipart/form-data"
              hx-target="#result" hx-swap="innerHTML">
          <.file_upload name="file" accept="image/*,.pdf" preserve />
          <button type="submit">Upload</button>
          <p id="result" aria-live="polite"></p>
        </form>
        <div class="w-full max-w-md p-6">
          <form id="ex-htmx-form" hx-post="/docs/file-upload/upload" hx-encoding="multipart/form-data" hx-target="#ex-htmx-result" hx-swap="innerHTML" class="grid gap-3">
            <div id="ex-htmx-fu" data-slot="file-upload" class="group/file-upload grid w-full gap-3 [&amp;.htmx-request]:opacity-70">
              <label data-slot="file-upload-zone" class="flex cursor-pointer flex-col items-center justify-center gap-2 rounded-md border border-dashed border-input bg-transparent px-6 py-8 text-center text-sm text-muted-foreground shadow-xs transition-[color,box-shadow] outline-none dark:bg-input/30 focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50 hover:border-ring/60 hover:text-foreground data-[dragover=true]:border-ring data-[dragover=true]:bg-accent data-[dragover=true]:text-foreground has-[input:disabled]:pointer-events-none has-[input:disabled]:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40">
                <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-6 shrink-0 opacity-70" aria-hidden="true">
                  <path d="M12 13v8">
                  </path>
                  <path d="m8 17 4-4 4 4">
                  </path>
                  <path d="M20 16.7A5 5 0 0 0 18 7h-1.26A8 8 0 1 0 4 15.25">
                  </path>
                </svg>
                <span class="font-medium text-foreground">Drop files here, or click to upload</span>
                <span class="text-xs">Any image or PDF</span>
                <input type="file" data-slot="file-upload-input" class="absolute size-px overflow-hidden border-0 p-0 whitespace-nowrap [clip:rect(0,0,0,0)]" name="file" accept="image/*,.pdf" hx-preserve="true" aria-label="Drop files here, or click to upload"/>
              </label>
              <ul data-slot="file-upload-list" class="m-0 grid list-none gap-2 p-0 empty:hidden" aria-live="polite">
              </ul>
            </div>
            <script>
              (function(el){el.setAttribute('data-file-upload-ready','true');})(document.currentScript.previousElementSibling);
            </script>
            <button type="submit" class="inline-flex h-9 items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90">Upload</button>
            <p id="ex-htmx-result" class="text-sm text-muted-foreground" aria-live="polite">No file uploaded yet.</p>
          </form>
        </div>

        API Reference

        <FileUpload>

        PropTypeDefaultDescription
        namestring
        Form field name. Required to submit; with multiple, one entry per file is sent under this name.MDN<input type="file">
        acceptstring
        Comma-separated unique file type specifiers (e.g. ".pdf,image/*"). Pre-filters the OS picker; the drop enhancement re-checks it.MDNaccept
        multiplebooleanfalse
        Allow choosing or dropping more than one file. The picker returns a FileList.MDNmultiple
        capture"user"|"environment"|boolean
        With an image/video accept, request the OS camera — user (front) or environment (rear).MDNcapture
        requiredbooleanfalse
        Native constraint — the form won't submit until a file is chosen.
        disabledbooleanfalse
        Disable the picker and dim the zone; not submitted with the form.
        preservebooleanfalse
        Emit hx-preserve="true" on the input so the chosen file survives when the form re-renders with validation errors during an htmx swap.htmxhx-preserve (file upload)
        formstring
        Associate the input with a <form> by id. Placing the input outside the swap target is an alternative to preserve for keeping the selection.MDNinput form
        labelstring"Drop files here, or click to upload"
        Visible prompt inside the zone. Also becomes the input's aria-label when no other accessible name is set.
        hintstring
        Secondary sub-label under the prompt (e.g. accepted types / size limit).
        showProgressbooleanfalse
        Render a native-style progressbar under the list. Drive it from XHR upload progress; htmx 4.x's fetch transport exposes upload progress only in some browsers.MDNXMLHttpRequest.upload
        progressnumber
        0–100 makes the bar determinate; omit (with showProgress) for the indeterminate animation.
        ariaInvalidboolean|"grammar"|"spelling"
        Mark the field invalid — red border + ring on the zone. Pair with a visible error via ariaDescribedby.MDNaria-invalid
        ariaLabelstring
        Accessible name when no visible <label>.MDNaria-label
        ariaLabelledbystring
        Id of a visible element providing the accessible name.MDNaria-labelledby
        ariaDescribedbystring
        Id of an element describing this control (announced after the name).MDNaria-describedby
        classstring
        Extra Tailwind classes appended to the root element.
        hx-*any
        Any htmx attribute. Forwarded onto the underlying element.htmxAttribute reference