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.json2. Use it
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
/** @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
{% 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
{# 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
tpl.ExecuteTemplate(w, "file-upload", map[string]any{
"Name": "files",
"Accept": "image/*",
"Multiple": true,
"Hint": "PNG, JPG up to 5MB",
"Preserve": true,
})View source
{{/*
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
<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
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
<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
<!--
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 [&.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 [&.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>Further reading
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.
<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 [&.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>
| Prop | Type | Default | Description |
|---|---|---|---|
name | string | — | Form field name. Required to submit; with multiple, one entry per file is sent under this name.MDN<input type="file"> |
accept | string | — | Comma-separated unique file type specifiers (e.g. ".pdf,image/*"). Pre-filters the OS picker; the drop enhancement re-checks it.MDNaccept |
multiple | boolean | false | 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 |
required | boolean | false | Native constraint — the form won't submit until a file is chosen. |
disabled | boolean | false | Disable the picker and dim the zone; not submitted with the form. |
preserve | boolean | false | 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) |
form | string | — | 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 |
label | string | "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. |
hint | string | — | Secondary sub-label under the prompt (e.g. accepted types / size limit). |
showProgress | boolean | false | 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 |
progress | number | — | 0–100 makes the bar determinate; omit (with showProgress) for the indeterminate animation. |
ariaInvalid | boolean|"grammar"|"spelling" | — | Mark the field invalid — red border + ring on the zone. Pair with a visible error via ariaDescribedby.MDNaria-invalid |
ariaLabel | string | — | Accessible name when no visible <label>.MDNaria-label |
ariaLabelledby | string | — | Id of a visible element providing the accessible name.MDNaria-labelledby |
ariaDescribedby | string | — | Id of an element describing this control (announced after the name).MDNaria-describedby |
class | string | — | Extra Tailwind classes appended to the root element. |
hx-* | any | — | Any htmx attribute. Forwarded onto the underlying element.htmxAttribute reference |