Vanilla UI Kit Upload v1.0.0 · toast →

Uploads, one file.

A zero-dependency dropzone with a managed file list — click, drag, or paste; thumbnails, validation, progress, and retry. Every card below is live.

Basic single file

One file at a time; picking another replaces it. Keyboard: Tab to the zone, Enter or Space opens the picker.

new Upload('#box')

Multiple + image thumbnails

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/*'
})

Validation errors

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
})

Custom upload fn + retry

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' }

Enhance an input in a real <form>

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

Family theming

With the VC core loaded, one call themes every component.

VC.config({ accent: '#b45309' })
// standalone: .vup { --vup-accent: #b45309 }

API reference

Everything below is verified against upload.js v1.0.0 — the single file is the source of truth.

Constructor & usage

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

Options

Every key of Upload.defaults, with its exact default from the source. Options merge over the defaults; undefined values are ignored.

OptionTypeDefaultDescription
multiplebooleanfalse false = single-file mode: a new pick replaces the current file.
acceptstring | nullnull Accept string like 'image/*,.pdf' — set on the input and validated on drop/paste (extension, type/* wildcard, or exact MIME match; empty accepts anything).
maxSizenumber | nullnull Max size in bytes; larger files get an inline error row with a human-readable size (via formatBytes).
maxFilesnumber | nullnull Cap on managed files. Validation-rejected rows don't count toward the cap.
textstring | nullnull Full dropzone prompt; when set it overrides labels.drop + labels.browse.
listPositionstring'below' 'below' renders the managed <ul> under the zone; 'none' renders no list (drive your own UI via callbacks).
autoUploadbooleanfalse true = upload immediately on add; the native input's value is cleared after each pick and is not mirrored for form submits.
uploadfunction | object | nullnull The transport — a function or an XHR config object. See the upload contract below.
namestring | nullnull Container mode only: name= for the created input, so it submits with a parent form. null = no name.
onAddfunction | nullnull fn(file, instance) — a file passed validation and was added.
onRemovefunction | nullnull fn(file, instance) — a file was removed.
onProgressfunction | nullnull fn(file, pct, instance) — pct is an integer 0..100.
onDonefunction | nullnull fn(file, response, instance) — upload finished.
onErrorfunction | nullnull fn(file, error, instance) — fires for validation and upload errors; error is an Error.
themestring'auto' 'auto' | 'light' | 'dark'. Auto follows the page and re-resolves live.
stylesbooleantrue false = headless: no CSS injected; style the .vup-* markup yourself.
labelsobjectsee below All UI strings; merged shallowly over the defaults, so overriding one key keeps the rest.

labels.* defaults

Templates interpolate {name}, {error}, {size}, {max} placeholders.

KeyDefaultUsed 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.

The upload contract

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.

KeyDefaultDescription
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.
withCredentialsfalseSet 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)'.

Instance methods

All mutators return the instance (chainable) except uploadAll(), which returns a Promise.

MethodReturnsDescription
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.

File records

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.

KeyTypeDescription
fileFileThe original File object.
statusstring'pending' | 'uploading' | 'done' | 'error'. Validation failures enter as 'error' with no retry.
responseanyThe upload's resolution value (or the built-in uploader's parsed body); null until done.
errorstring | nullHuman-readable error message; null unless the row is in error.

Statics & helpers

StaticDescription
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.

Events & callbacks

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.

CallbackArgumentsFires 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).

Declarative init

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> -->
AttributeMaps toValue
data-vup—Marker: opt this element in to auto-init.
data-vup-multiplemultipleBoolean string.
data-vup-acceptacceptAccept string.
data-vup-max-sizemaxSizeNumber (bytes); coerced with +.
data-vup-max-filesmaxFilesNumber; coerced with +.
data-vup-texttextPrompt string.
data-vup-namenameInput name string.
data-vup-list-positionlistPositionbelow | none.
data-vup-auto-uploadautoUploadBoolean string.
data-vup-themethemeauto | light | dark.
data-vup-stylesstylesBoolean string.
data-vup-actionupload.actionURL — its presence enables the built-in XHR uploader.
data-vup-methodupload.methodHTTP method (only read alongside data-vup-action).
data-vup-field-nameupload.fieldNameFormData field (only read alongside data-vup-action).

Callbacks and a custom upload function cannot be expressed declaratively — construct those instances from JS.

Theming

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]:

PropertyLight defaultDark 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-softrgba(91,91,214,.13)*(inherits light)*
--vup-danger-softrgba(229,72,77,.12)*(inherits light)*
--vup-shadowlayered rgba shadowdeeper layered shadow
--vup-radius12px(inherits light)
--vup-fontsystem-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

Accessibility

KeyAction
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.

Headless mode

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.

Unstyled (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
})

Bring your own design (Tailwind)

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>