A zero-dependency dropzone with a managed file list — click, drag, or paste; thumbnails, validation, progress, and retry. Every card below is live.
One file at a time; picking another replaces it. Keyboard: Tab to the zone, Enter or Space opens the picker.
new Upload('#box')
Drop a few images — previews render from object URLs (revoked on remove). Paste works too while the zone is focused.
new Upload('#box', {
multiple: true,
accept: 'image/*'
})
Images only, max 200 KB, max 3 files. Rejected files get an inline danger row — nothing throws, valid files still land.
new Upload('#box', {
multiple: true,
accept: 'image/*',
maxSize: 200 * 1024, // '200 KB' in the error
maxFiles: 3
})
A simulated uploader with progress. Files whose name contains
fail reject — the row gets a retry button (a retry
succeeds). Or use the buttons to add sample files.
new Upload('#box', {
multiple: true,
upload: function (file, onProgress) { // onProgress(0..1)
return fakeServer(file, onProgress) // → Promise
},
onDone: (file, res) => log(file.name + ' → ' + res.url)
})
// built-in XHR uploader instead:
// upload: { action: '/api/upload', fieldName: 'file' }
The original <input type=file> is wrapped and
hidden but keeps submitting with the form — dragged files are mirrored
onto it. Submit to see what the form would send.
new Upload(document.getElementById('attachments'))
// destroy() restores the native input exactly
With the VC core loaded, one call themes every component.
VC.config({ accent: '#b45309' })
// standalone: .vup { --vup-accent: #b45309 }
Everything below is verified against upload.js v1.0.0 — the
single file is the source of truth.
new Upload(target, options?) — target is a CSS
selector string or a DOM element. There are two mount modes, decided by what
the target is:
new Upload('#box', opts) // CONTAINER mode: the widget is appended into the
// element and a real <input type=file> is created
// inside it — give it opts.name and it still submits
// with a parent <form> while autoUpload is off
new Upload(fileInput, opts) // ENHANCE mode: an existing <input type=file> is
// wrapped and visually hidden (never display:none),
// so it keeps submitting with its form;
// destroy() restores it exactly as it was
In enhance mode, multiple and accept are adopted
from the input's own attributes when not passed as options. Constructing on an
element that already holds an instance destroys the old instance first. With a
null/missing target or no DOM (SSR), the constructor returns an
inert no-op handle whose whole instance API is safe to call
(uploadAll() resolves to [],
getFiles() returns []). An unmatched selector or
non-element target throws
Upload: target element not found.
Upload.create('#box', opts) // static alias — identical to new Upload(...)
Upload.get('#box') // instance registered on that element, or null
Every key of Upload.defaults, with its exact default from the
source. Options merge over the defaults; undefined values are
ignored.
| Option | Type | Default | Description |
|---|---|---|---|
multiple | boolean | false |
false = single-file mode: a new pick replaces the current file. |
accept | string | null | null |
Accept string like 'image/*,.pdf' — set on the input and validated on drop/paste (extension, type/* wildcard, or exact MIME match; empty accepts anything). |
maxSize | number | null | null |
Max size in bytes; larger files get an inline error row with a human-readable size (via formatBytes). |
maxFiles | number | null | null |
Cap on managed files. Validation-rejected rows don't count toward the cap. |
text | string | null | null |
Full dropzone prompt; when set it overrides labels.drop + labels.browse. |
listPosition | string | 'below' |
'below' renders the managed <ul> under the zone; 'none' renders no list (drive your own UI via callbacks). |
autoUpload | boolean | false |
true = upload immediately on add; the native input's value is cleared after each pick and is not mirrored for form submits. |
upload | function | object | null | null |
The transport — a function or an XHR config object. See the upload contract below. |
name | string | null | null |
Container mode only: name= for the created input, so it submits with a parent form. null = no name. |
onAdd | function | null | null |
fn(file, instance) — a file passed validation and was added. |
onRemove | function | null | null |
fn(file, instance) — a file was removed. |
onProgress | function | null | null |
fn(file, pct, instance) — pct is an integer 0..100. |
onDone | function | null | null |
fn(file, response, instance) — upload finished. |
onError | function | null | null |
fn(file, error, instance) — fires for validation and upload errors; error is an Error. |
theme | string | 'auto' |
'auto' | 'light' | 'dark'. Auto follows the page and re-resolves live. |
styles | boolean | true |
false = headless: no CSS injected; style the .vup-* markup yourself. |
labels | object | see below | All UI strings; merged shallowly over the defaults, so overriding one key keeps the rest. |
Templates interpolate {name}, {error},
{size}, {max} placeholders.
| Key | Default | Used for |
|---|---|---|
zone | 'Choose files' | Dropzone's aria-label (the accessible button name). |
drop | 'Drop files here or' | Visible prompt text (unless text is set). |
browse | 'browse' | Accent-colored word inside the prompt. |
files | 'Selected files' | aria-label of the file list. |
remove | 'Remove {name}' | Per-file remove button aria-label. |
retry | 'Retry uploading {name}' | Per-file retry button aria-label. |
progress | 'Uploading {name}' | Progress bar aria-label. |
added | '{name} added' | Live-region announcement. |
removed | '{name} removed' | Live-region announcement. |
uploaded | '{name} uploaded' | Live-region announcement. |
failed | '{name} failed: {error}' | Live-region announcement (validation or upload failure). |
done | 'Uploaded' | Suffix on a finished row's size line. |
tooLarge | 'File is larger than {size}' | Inline error for maxSize. |
badType | 'File type not accepted' | Inline error for accept. |
tooMany | 'No more than {max} files' | Inline error for maxFiles. |
uploadError | 'Upload failed' | Fallback error text when an upload rejects without a message. |
upload takes either a function or
a config object.
Function — you own the transport. Exact signature:
upload(file, onProgress), where file is the
File and onProgress(ratio) takes a ratio
0..1 (clamped; rendered as a 0..100 percentage). Return a
Promise/thenable: resolve = done and the resolution value becomes the
item's response; reject = error — the rejection's
.message (or labels.uploadError) becomes the row's
error text and a retry button appears. A non-thenable return value counts as
instantly done, with that value as the response. A synchronous throw is
treated as a rejection. There is no abort hook for a custom function: on
remove/destroy an internal token simply discards the late settlement.
upload: function (file, onProgress) {
return myApi.put('/files', file, { onUploadProgress: function (e) {
onProgress(e.loaded / e.total)
}})
}
Config object — the built-in XMLHttpRequest
uploader. The file is sent as multipart/form-data via
FormData.append(fieldName, file, file.name); real
xhr.upload.onprogress events drive the bar (only when
lengthComputable). The XHR is kept on the item so
removeFile() / clear() / destroy() can
abort it mid-flight. A config without action fails immediately
with Upload: no `upload.action` configured.
| Key | Default | Description |
|---|---|---|
action | — (required) | Request URL. |
method | 'POST' | HTTP method; uppercased before open(). |
fieldName | 'file' | FormData field the file rides in. |
headers | — | Plain object; each entry is applied with setRequestHeader. |
withCredentials | false | Set xhr.withCredentials = true when truthy. |
Completion: any 2xx status resolves; the response body is
JSON-parsed when possible, else kept as raw text, and becomes
response. Non-2xx (or a network error, status 0) rejects with an
Error carrying .status and .response;
its message is a JSON body's message if present, else
'Upload failed (status)'.
All mutators return the instance (chainable) except
uploadAll(), which returns a Promise.
| Method | Returns | Description |
|---|---|---|
addFiles(files) | this | Add a FileList, File[], or single File through the same validation path as click/drop/paste. In single-file mode the new pick replaces the managed list. Uploads start immediately when autoUpload is on. |
removeFile(file) | this | Remove by File identity: aborts an in-flight built-in upload, revokes the thumbnail object URL, drops the row, fires onRemove. |
uploadAll() | Promise | Starts every 'pending' file (nothing else). Resolves with getFiles() once all started uploads settle (removal mid-flight counts as settled). Never rejects. Where Promise is absent it starts the uploads and returns null. |
getFiles() | Array | Snapshot of the managed list — one record per row, see the shape below. |
clear() | this | Remove everything (aborting in-flight uploads) and reset the native input's value. |
enable() | this | Re-enable after disable(): restores the zone's tabindex="0", clears aria-disabled, re-enables the input. |
disable() | this | Disable interaction: adds .is-disabled, sets aria-disabled="true", takes the zone out of the tab order, disables the input. |
destroy() | this | Abort uploads, revoke URLs, unbind all listeners, remove the widget. Enhance mode restores the original input's position, class, tabindex, aria-hidden, and disabled state exactly. |
Each record returned by getFiles() (and resolved by
uploadAll()) has exactly these keys; name, size, and MIME type
live on the File itself. Internally each row also tracks a
stable id, a 0..1 progress, the preview object URL,
and the in-flight XHR, but those are not part of the public snapshot.
| Key | Type | Description |
|---|---|---|
file | File | The original File object. |
status | string | 'pending' | 'uploading' | 'done' | 'error'. Validation failures enter as 'error' with no retry. |
response | any | The upload's resolution value (or the built-in uploader's parsed body); null until done. |
error | string | null | Human-readable error message; null unless the row is in error. |
| Static | Description |
|---|---|
Upload.formatBytes(n) |
Human-readable byte counts in B / KB / MB / GB / TB (1024-based): one decimal below 10 in a non-byte unit, trailing .0 trimmed — formatBytes(1536) → '1.5 KB', 524288 → '512 KB'. Negative or non-finite input returns ''. |
Upload.defaults |
The live defaults object from the options table above — mutate it before constructing to change global defaults (e.g. Upload.defaults.styles = false). |
Upload.get(target) |
Selector or element → the instance registered on it, or null. |
Upload.create(target, options) |
Alias for new Upload(target, options). |
Upload.autoInit(root?) |
Scan root (default document) for [data-vup] elements and construct instances; returns the created array. Skips already-initialized elements; one bad element logs and does not abort the rest. Runs automatically on DOMContentLoaded. |
Upload.salt |
CSS isolation token, default 'vc1'. Set your own string or false (no salting) before the first instance. |
Upload.css |
The full stylesheet as a string — a live getter, always rendered with the current salt. Starting point for headless styling. |
Upload.version |
'1.0.0'. |
Upload.displayName |
'Upload' (family convergence contract). |
Upload.rootClass |
'vup' — the widget root's class (family convergence contract). |
Upload.themeVars |
Map used by the VC core bridge: { accent: '--vup-accent', radius: '--vup-radius', font: '--vup-font' }. |
Upload.varScopes |
['.vup', '.vup[data-theme=dark]'] — the (deliberately unsalted) scopes where theme variables are defined. |
All notifications are option callbacks — the component
dispatches no DOM CustomEvents. Every callback receives the
instance as its final argument; a callback that throws is caught and logged
(console.error) so it can never break the upload pipeline.
Return values are ignored — there is no beforeUpload-style
veto hook; use accept / maxSize /
maxFiles for client-side rejection.
| Callback | Arguments | Fires when |
|---|---|---|
onAdd | (file, instance) |
A file passed validation and joined the managed list (click, drop, paste, or addFiles). |
onRemove | (file, instance) |
A file was removed via the row button or removeFile(). Not fired by clear() or destroy(). |
onProgress | (file, pct, instance) |
Upload progress; pct is an integer 0..100 (also fired at 0 on start and 100 on completion). |
onDone | (file, response, instance) |
An upload settled successfully; response is the record's response value. |
onError | (file, error, instance) |
A validation failure (type / size / count — error is an Error wrapping the label text) or an upload failure (the rejection, with .status/.response from the built-in uploader). |
Any element with data-vup is auto-initialized on
DOMContentLoaded (or immediately when the script loads later);
Upload.autoInit(root?) re-scans on demand. A
<div data-vup> mounts in container mode, an
<input type=file data-vup> in enhance mode. Boolean values
parse as true unless the string is "false" or "0".
<div data-vup data-vup-multiple="true" data-vup-accept="image/*"
data-vup-max-size="2097152" data-vup-action="/api/upload"
data-vup-auto-upload="true"></div>
<!-- or enhance: <input type="file" data-vup multiple> -->
| Attribute | Maps to | Value |
|---|---|---|
data-vup | — | Marker: opt this element in to auto-init. |
data-vup-multiple | multiple | Boolean string. |
data-vup-accept | accept | Accept string. |
data-vup-max-size | maxSize | Number (bytes); coerced with +. |
data-vup-max-files | maxFiles | Number; coerced with +. |
data-vup-text | text | Prompt string. |
data-vup-name | name | Input name string. |
data-vup-list-position | listPosition | below | none. |
data-vup-auto-upload | autoUpload | Boolean string. |
data-vup-theme | theme | auto | light | dark. |
data-vup-styles | styles | Boolean string. |
data-vup-action | upload.action | URL — its presence enables the built-in XHR uploader. |
data-vup-method | upload.method | HTTP method (only read alongside data-vup-action). |
data-vup-field-name | upload.fieldName | FormData field (only read alongside data-vup-action). |
Callbacks and a custom upload function cannot be expressed declaratively — construct those instances from JS.
With theme: 'auto' (default) the resolution order is:
<html data-theme> / data-bs-theme →
.dark / .light class on <html> →
prefers-color-scheme — re-resolved live via a
MutationObserver and a media-query listener (or delegated to the shared VC
theme engine when the core is loaded). Pin per instance with
theme: 'light' or 'dark'; the resolved theme lands
as data-theme on the widget root. With the VC core loaded,
VC.config({ accent: '#b45309' }) themes the whole family.
All colors are CSS custom properties, defined on .vup (light)
and re-defined on .vup[data-theme=dark]:
| Property | Light default | Dark default |
|---|---|---|
--vup-accent | #5b5bd6 | #7b7bea |
--vup-danger | #e5484d | #f2555a |
--vup-success | #1f9d5b | #4ccb8f |
--vup-bg | #ffffff | #1b1d24 |
--vup-surface | #f2f2f5 | #272a33 |
--vup-text | #1c1d21 | #e9eaf0 |
--vup-muted | #72747e | #989aa6 |
--vup-faint | #e7e7ec | #31343f |
--vup-accent-soft | rgba(91,91,214,.13)* | (inherits light)* |
--vup-danger-soft | rgba(229,72,77,.12)* | (inherits light)* |
--vup-shadow | layered rgba shadow | deeper layered shadow |
--vup-radius | 12px | (inherits light) |
--vup-font | system-ui stack | (inherits light) |
* Where color-mix() is supported, the soft tints are derived
from the current --vup-accent / --vup-danger
automatically, so overriding the accent recolors them too.
CSS isolation: the root renders as
class="vup vc1" and every structural rule ships salted
(.vup.vc1 .vup-zone { … }), so host-page design systems can't
override the widget — while variable definitions stay unsalted at
their documented specificity, so .vup { --vup-accent: … } page
overrides keep working. Set Upload.salt = 'acme' (sanitized to
[\w-]) or Upload.salt = false before the first
instance. The injected <style> (id
vanilla-upload-styles) is inserted before the page's
first stylesheet so page overrides win.
Headless: styles: false per instance, or
Upload.defaults.styles = false globally, injects no CSS while
keeping the full behavior (drag counting, validation, uploads, ARIA).
Upload.css is the stylesheet string to start from. The markup
contract you style:
.vup[data-theme="dark"].is-drag.is-disabled
.vup-zone[role=button] ← .vup-zone-icon + .vup-prompt > .vup-browse
.vup-live ← visually-hidden polite live region
input.vup-native ← the real file input, visually hidden
ul.vup-list
li.vup-item[data-status=pending|uploading|done|error]
.vup-thumb > img|svg
.vup-meta > .vup-name + .vup-sub|.vup-errmsg + .vup-bar > .vup-fill
.vup-retry ← only after an upload error
.vup-remove
role="button" with
tabindex="0": its accessible name is labels.zone
(aria-label) and the visible prompt describes it via
aria-describedby. disable() sets
aria-disabled="true" and removes it from the tab order.<input type=file> stays in the DOM (so it
keeps submitting with a form) but is visually hidden with
tabindex="-1" and aria-hidden="true".aria-live="polite") announces added / removed / uploaded /
failed files, clearing first so repeated messages re-announce.<ul> labelled with
labels.files; progress bars are
role="progressbar" with aria-valuemin/max and a
live aria-valuenow, labelled per file via
labels.progress.<button type="button">
elements with per-file aria-labels
(labels.remove / labels.retry).:focus-visible rings only;
prefers-reduced-motion: reduce disables all transitions and
animations.| Key | Action |
|---|---|
Tab / Shift+Tab |
Move between the dropzone and each row's retry / remove buttons (the hidden input is skipped). |
Enter or Space (dropzone focused) |
Open the file picker. Space's default is prevented so the page doesn't scroll; legacy keyCode 13/32 and 'Spacebar' are also handled. |
Ctrl+V / Cmd+V (dropzone focused) |
Attach files from the clipboard. The paste listener is document-level but only acts while the dropzone is the active element, so it never hijacks paste elsewhere. |
Enter / Space (row button focused) |
Native button activation: retry a failed upload or remove the file. There is no separate Delete-key shortcut on rows. |
styles: false injects nothing — you keep the full behavior
(drag counting, validation, uploads), the ARIA wiring with a live region,
and the stable .vup-* class hooks; you bring your own CSS.
Each demo runs in its own <iframe>: the styled examples
above already injected the upload stylesheet into this page, so only a
clean document can show truly unstyled output.
styles: false)The raw markup contract — dropzone, live region, the real file input (visible, since no CSS hides it) and a plain list, zero CSS injected. Pick a file to see raw rows appear.
new Upload('#box', {
styles: false, // zero CSS injected
multiple: true
})
The same headless uploader mapped to a different design — dropzone
drag state, progress fill, done/error rows — with a simulated transport
so progress animates. Loaded on demand from
cdn.tailwindcss.com.
<style type="text/tailwindcss">
.vup { @apply mx-auto max-w-md font-sans; }
.vup-zone { @apply flex cursor-pointer flex-col
items-center gap-1 rounded-2xl border-2 border-dashed
border-slate-300 bg-white px-6 py-8 text-sm
text-slate-500 transition-colors; }
.vup.is-drag .vup-zone { @apply border-indigo-500
bg-indigo-50 text-indigo-600; }
.vup-zone-icon { @apply text-slate-400; }
.vup.is-drag .vup-zone-icon { @apply text-indigo-500; }
.vup-browse { @apply cursor-pointer border-0 bg-transparent
p-0 font-semibold text-indigo-600 underline
underline-offset-2; }
.vup-native, .vup-live { @apply sr-only; }
.vup-list { @apply m-0 mt-3 list-none space-y-2 p-0; }
.vup-item { @apply flex items-center gap-3 rounded-xl
border border-slate-200 bg-white p-3; }
.vup-item[data-status="error"] { @apply border-rose-300
bg-rose-50; }
.vup-thumb { @apply grid h-9 w-9 flex-none
place-items-center overflow-hidden rounded-lg
bg-slate-100 text-slate-400; }
.vup-thumb img { @apply h-full w-full object-cover; }
.vup-meta { @apply min-w-0 flex-1; }
.vup-name { @apply block truncate text-sm font-medium
text-slate-700; }
.vup-sub { @apply text-xs text-slate-400; }
.vup-errmsg { @apply text-xs font-medium text-rose-600; }
.vup-bar { @apply mt-1.5 h-1.5 overflow-hidden
rounded-full bg-slate-100; }
.vup-fill { @apply h-full rounded-full bg-indigo-500
transition-[width]; }
.vup-item[data-status="done"] .vup-fill {
@apply bg-emerald-500; }
.vup-remove, .vup-retry { @apply grid h-7 w-7 flex-none
cursor-pointer place-items-center rounded-full border-0
bg-transparent text-slate-400 hover:bg-slate-100
hover:text-slate-600; }
</style>