Skip to content
ShineUI
Install

FileUpload

Drop it here.

<sui-file-upload>MiscStable

About

Dropzone with a queue of file rows in uploading, done and error states. Picked or dropped files come out through suiSelect; give it a url and it uploads them and shows real progress, or drive files from the host. Without url, a plain form posts the queued files under name. Use for receipts, CSV imports and logo upload.

Usage

<sui-file-upload help="PDF, CSV, JPG, up to 25 MB" accept=".pdf,.csv,image/*" multiple></sui-file-upload> <script type="module"> import { defineCustomElements } from "@shineui/core/loader"; import "@shineui/core/shineui.css"; defineCustomElements(); const el = document.querySelector("sui-file-upload"); el.files = []; /* SuiFileRow[] */ el.addEventListener("suiSelect", (e) => console.log(e.detail)); </script>
import { SuiFileUpload } from "@shineui/react"; import "@shineui/core/shineui.css"; export function Example() { const files = []; /* SuiFileRow[] */ return <SuiFileUpload help="PDF, CSV, JPG, up to 25 MB" accept=".pdf,.csv,image/*" multiple files={files} onSuiSelect={(e) => console.log(e.detail)} />; }
<script setup> import { SuiFileUpload } from "@shineui/vue"; import "@shineui/core/shineui.css"; const files = []; // SuiFileRow[] const onChange = (e) => console.log(e.detail); </script> <template> <SuiFileUpload help="PDF, CSV, JPG, up to 25 MB" accept=".pdf,.csv,image/*" multiple :files="files" @sui-select="onChange" /> </template>
import { Component } from "@angular/core"; import { SuiFileUpload } from "@shineui/angular"; @Component({ standalone: true, imports: [SuiFileUpload], template: `<sui-file-upload help="PDF, CSV, JPG, up to 25 MB" accept=".pdf,.csv,image/*" multiple [files]="files" (suiSelect)="onChange($event)"></sui-file-upload>`, }) export class Example { files = []; // SuiFileRow[] onChange(e: CustomEvent) { console.log(e.detail); } }

Basic

accept is the native filter, as on <input type="file">; help is the line under the title; multiple lets one pick or drop queue several files. The zone is role="button" named "Upload files". Each row has its own "Remove {name}" button, and an uploading row is a progressbar named "Uploading {name}".

<div style="max-width:460px"> <sui-file-upload help="PDF, CSV, JPG, up to 25 MB" accept=".pdf,.csv,image/*" multiple></sui-file-upload> </div>

Queue states

files is a property holding the rows. A pick appends done rows and the host replaces the list to drive real progress; uploading shows a bar and a percent, error shows its message in place of the size.

<div style="max-width:460px"><sui-file-upload id="fu-states" multiple></sui-file-upload></div>
// setup(root): properties and events setup(root) { root.querySelector('#fu-states').files = queue(); }

Uploading to a url

Give it a url and it posts every accepted file itself, one request per file, and drives each row from the real upload progress. This example answers with a stub the page installs, so nothing leaves your browser.

Idle.
<div style="display:grid;gap:10px;max-width:460px"> <sui-file-upload id="fu-url" url="/stub/receipts" name="receipt" help="Drop a receipt to watch it upload" multiple></sui-file-upload> <span id="fu-url-log" style="font:460 13px/1.6 var(--font-sans);color:var(--ink-muted)">Idle.</span> </div>
// setup(root): properties and events setup(root) { const el = root.querySelector('#fu-url'); const log = root.querySelector('#fu-url-log'); lendStub(root, (xhr, on, up) => stubRun(xhr, on, up, (x, handlers) => { x.status = 201; x.responseText = '{"id":"rcpt_2209"}'; handlers.load(); })); el.addEventListener('suiProgress', (e) => { log.textContent = `${e.detail.file.name} at ${e.detail.progress}%.`; }); el.addEventListener('suiUpload', (e) => { log.textContent = `${e.detail.file.name} stored, status ${e.detail.status}.`; }); }

When an upload fails

A failed request turns the row red and adds a retry control beside remove; suiUploadError carries the status and the body. The stub here refuses the first attempt and takes the second.

Idle.
<div style="display:grid;gap:10px;max-width:460px"> <sui-file-upload id="fu-fail" url="/stub/receipts" help="Drop a file to see it fail once"></sui-file-upload> <span id="fu-fail-log" style="font:460 13px/1.6 var(--font-sans);color:var(--ink-muted)">Idle.</span> </div>
// setup(root): properties and events setup(root) { const el = root.querySelector('#fu-fail'); const log = root.querySelector('#fu-fail-log'); const tried = new Set(); lendStub(root, (xhr, on, up) => stubRun(xhr, on, up, (x, handlers) => { const first = !tried.has(x.field); tried.add(x.field); x.status = first ? 507 : 200; x.responseText = first ? 'Storage full' : 'ok'; handlers.load(); })); el.addEventListener('suiBeforeUpload', (e) => { e.detail.xhr.field = e.detail.file.name; }); el.addEventListener('suiUploadError', (e) => { log.textContent = `Refused with status ${e.detail.status}. Use the retry control.`; }); el.addEventListener('suiUpload', () => { log.textContent = 'Second attempt stored.'; }); }

Image previews

An image row shows the image instead of the file-type icon. Set thumbnail on a row yourself for a file already on your server.

<div style="max-width:460px"><sui-file-upload id="fu-thumbs" help="Drop a PNG or JPG" accept="image/*" multiple></sui-file-upload></div>
// setup(root): properties and events setup(root) { root.querySelector('#fu-thumbs').files = [ { name: 'volunteer-day.png', size: '1.8 MB', status: 'done', thumbnail: '/docs/assets/imagery/nonprofits.webp' }, { name: 'w9-scan.pdf', size: '96 KB', status: 'done' }, ]; }

Compact and single file

compact tightens the dropzone padding for a side panel. Without multiple the zone takes one file, as a native file input does: the picker offers one and a drop of several keeps the first. That suits a logo or a signed form.

<div style="display:grid;gap:14px;max-width:460px"> <sui-file-upload id="fu-compact" compact help="Drop the signed W-9"></sui-file-upload> <sui-file-upload id="fu-single" compact help="One logo, PNG or SVG" accept=".png,.svg"></sui-file-upload> </div>
// setup(root): properties and events setup(root) { const single = root.querySelector('#fu-single'); single.files = [{ name: 'falls-city-logo.png', size: '48 KB', status: 'done' }]; }

Disabled

disabled locks the zone and the row controls: the picker cannot open, a drop is ignored, and the zone leaves the tab order. Use it while a form saves.

<div style="max-width:460px"><sui-file-upload id="fu-disabled" disabled help="Saving, hold on"></sui-file-upload></div>
// setup(root): properties and events setup(root) { root.querySelector('#fu-disabled').files = [{ name: 'receipt-bill-2209.pdf', size: '182 KB', status: 'done' }]; }

Limits

accept, maxFileSize and fileLimit refuse rather than error: a refused file never enters the queue and comes out through suiReject, so the host decides how to say so. Size and count are checked on picks and drops alike; accept is re-applied only on a drop, because the browser already filtered a pick.

Drop a JPG, or something over 1 MB, to see a refusal.
<div style="display:grid;gap:10px;max-width:460px"> <sui-file-upload id="fu-limits" help="PDF or CSV, up to 1 MB, two files" accept=".pdf,.csv" multiple></sui-file-upload> <span id="fu-limits-log" style="font:460 13px/1.6 var(--font-sans);color:var(--ink-muted)">Drop a JPG, or something over 1 MB, to see a refusal.</span> </div>
// setup(root): properties and events setup(root) { const el = root.querySelector('#fu-limits'); const log = root.querySelector('#fu-limits-log'); el.maxFileSize = 1048576; el.fileLimit = 2; el.addEventListener('suiReject', (e) => { log.textContent = `Refused ${e.detail.map((f) => f.name).join(', ')}.`; }); el.addEventListener('suiSelect', (e) => { log.textContent = `Queued ${e.detail.length} file(s).`; }); }

Events

suiSelect carries the native File objects that were accepted, suiReject carries the ones a rule refused, and suiRemove carries the row a remove control dropped. With no url set, wire suiSelect to your own upload and replace files with the rows it reports.

Idle.
<div style="display:grid;gap:10px;max-width:460px"> <sui-file-upload id="fu-events" help="PDF, CSV or JPG" accept=".pdf,.csv,.jpg,.jpeg" multiple></sui-file-upload> <span id="fu-log" style="font:460 13px/1.6 var(--font-sans);color:var(--ink-muted)">Idle.</span> </div>
// setup(root): properties and events setup(root) { const el = root.querySelector('#fu-events'); const log = root.querySelector('#fu-log'); el.addEventListener('suiSelect', (e) => { log.textContent = `Selected ${e.detail.map((f) => f.name).join(', ')}.`; }); el.addEventListener('suiReject', (e) => { log.textContent = `Refused ${e.detail.map((f) => f.name).join(', ')}. Nothing was queued for those.`; }); el.addEventListener('suiRemove', (e) => { log.textContent = `Removed ${e.detail.name}.`; }); }

In a form

The element is form-associated. Without a url every queued file posts under name as a multipart entry, picked or dropped, and a removed row leaves the post. With a url the files go up on their own and the form posts nothing for them. required holds the submit until a file is queued.

Pick a receipt, then submit.
<form id="fu-form" style="display:grid;gap:14px;max-width:460px"> <sui-input-text label="Bill reference" name="reference" value="BILL-2209"></sui-input-text> <sui-file-upload name="receipt" label="Receipt" required compact help="PDF or JPG" accept=".pdf,.jpg,.jpeg"></sui-file-upload> <div style="display:flex;gap:8px"> <sui-button type="submit" variant="brand" label="Attach to bill"></sui-button> </div> <output id="fu-out" style="font:460 13px/1.6 var(--font-sans);color:var(--ink-muted)">Pick a receipt, then submit.</output> </form>
// setup(root): properties and events setup(root) { const form = root.querySelector('#fu-form'); const out = root.querySelector('#fu-out'); form.addEventListener('submit', (e) => { e.preventDefault(); const shown = (v) => (typeof v === 'string' ? v || '(empty)' : v.name || '(no file)'); out.textContent = [...new FormData(form)].map(([k, v]) => `${k}=${shown(v)}`).join(' '); }); }

Keyboard

KeyAction
Enter SpaceOn the dropzone: opens the native file picker.

API

<sui-file-upload>

Properties

PropertyAttributeTypeDefaultDescription
acceptacceptstringstring | undefinedNative accept filter for the picker, e.g. ".pdf,.csv,image/*". Omit to allow any file. A dropped file that fails it is refused through suiReject; on a pick the browser has already applied it.
compactcompactbooleanfalseTighter dropzone padding (16px instead of 26px 16px) for a side panel.
disableddisabledbooleanfalseLocks the zone and the row controls. The picker cannot open, a drop is ignored, the zone leaves the tab order, and the element posts nothing. A disabled fieldset or form does the same. An upload already in flight runs to its end.
errorerrorstringstring | undefinedError message under the queue; paints the zone's edge in the danger tone and fails the form (customError).
fileLimitfile-limitnumbernumber | undefinedMost rows the queue may hold, counting rows already there. Omit for no limit. Files beyond it are refused through suiReject.
filesproperty onlySuiFileRow[][]Queued rows (the reference demoFiles). Property, not attribute. Mutable: picks append rows, the remove control drops them, and url mode writes progress onto them as it uploads.
helphelpstringstring | undefinedHelp line under the dropzone title, as every field has one. Prose for the reader, so write the limits out in words; accept and maxFileSize are what the element enforces. Unset, the element says the size limit if there is one. It used to say "PDF, CSV, JPG, up to 25 MB" whatever it was configured to accept, which is one customer's upload form and was a lie on anybody else's. With no help and no limit there is no help line, because there is nothing true to say.
labellabelstringstring | undefinedLabel above the dropzone; it also names the zone, before its own words.
maxFileSizemax-file-sizenumbernumber | undefinedLargest accepted file in bytes (25 MB is 26214400). Omit for no limit. Checked on picks and drops alike; an over-size file is refused through suiReject and never enters the queue.
multiplemultiplebooleanfalseAllows more than one file per pick, as the native multiple does. Off by default, as on a native file input: the picker takes one file and a drop of several keeps the first.
namenamestringstring | undefinedForm control name. Without url every queued row that holds a file, picked or dropped, posts under it as a multipart entry, and a removed row leaves the post (send the form with enctype="multipart/form-data"). With url it names the field the element sends each file under, and the form posts nothing for them, since they have gone up already. Reflected: the form entry is built from the content attribute, and all three framework bindings set props.
requiredrequiredbooleanfalseA file must be queued for the surrounding form to be valid (valueMissing while the queue holds no row that has not failed). A disabled upload never holds the form.
urlurlstringstring | undefinedEndpoint for the element's own upload. Set it and every accepted file is posted as it is queued, one request per file, with the row showing real progress. Leave it off and the host uploads through suiSelect and drives files itself. The limits apply either way: a refused file is never sent.

Events

EventDetailReactVueDescription
suiBeforeUploadSuiUploadRequestDetailonSuiBeforeUpload@sui-before-uploadFires in url mode once per file, with the request open and unsent. Set headers on xhr or add fields to body.
suiProgressSuiUploadProgressDetailonSuiProgress@sui-progressFires in url mode as a file goes up, at each whole percent. The row carries the same number, so listen only if something outside the queue shows it.
suiRejectFile[]onSuiReject@sui-rejectFires when accept, multiple, maxFileSize or fileLimit refuses files; detail is the refused File objects. A refused file never enters the queue, so the host decides how to say so.
suiRemoveSuiFileRowonSuiRemove@sui-removeFires when a row's remove control is used; detail is the removed row.
suiSelectFile[]onSuiSelect@sui-selectFires when files are chosen or dropped and pass every rule; detail is the accepted native File objects. Fires in url mode too, before the requests go out.
suiUploadSuiUploadResultDetailonSuiUpload@sui-uploadFires in url mode when a file lands, with the server's status and body text.
suiUploadErrorSuiUploadResultDetailonSuiUploadError@sui-upload-errorFires in url mode when a file fails to upload. The row turns to error with a retry control; status is 0 when the request never reached the server.

Methods

MethodSignatureDescription
setFocus()setFocus() => Promise<void>Moves keyboard focus to the dropzone. sui-form calls this to reach the first invalid control.
showPicker()showPicker() => Promise<void>Opens the native file picker, as the native input's showPicker() does.
upload()upload() => Promise<void>Sends every row that is not done yet, which is how a failed row is tried again. Needs url.

Types

SuiFileRow interfaceOne queued file row. size is display text ("182 KB"); progress is 0-100 while uploading.
namestring
size?string
status'uploading' | 'done' | 'error'
progress?number
error?string
file?FileThe file the row was made from. url mode keeps it so a failed row can be sent again.
thumbnail?stringImage preview source. The element fills it in for an image it queued, with an object URL it revokes once no row in files holds it any more; set it yourself on a row you restored from a server.
SuiUploadProgressDetail interfaceDetail of suiProgress: how far one file has got. progress is 0-100 and only whole steps are reported.
fileFile
loadednumber
totalnumber
progressnumber
SuiUploadRequestDetail interfaceDetail of suiBeforeUpload: one file's request, open and not yet sent. Set headers on xhr or add fields to body.
fileFile
bodyFormData
xhrXMLHttpRequest
SuiUploadResultDetail interfaceDetail of suiUpload and suiUploadError: one finished upload. status is the HTTP status, or 0 when the request never reached the server.
fileFile
statusnumber
responsestring

Theming

Every design token this element can render with, read from its own stylesheets and the ones it imports. Set a token on :root or on any ancestor and every use below follows it. The values shown are the ones in the theme you are viewing. The list is read per stylesheet, so an element that imports a shared drawing module lists that module's tokens too, whether or not it paints with all of them.

See also