UploadDropzone
Drag, paste, and validate file uploads.
Live demo
01Default Β· drag, drop, paste
Custom row snippet Β· icon + name + size
Pass a fileItem snippet to swap out the default progress row. Here we render a slim icon-name-size strip β handy for finished uploads or read-only listings.
Validation rejection Β· maxSize=100 bytes
maxSize is deliberately set to 100 bytes, so any real file fails the size check. The component never adds them to the list β it routes the failure through onFilesRejected with a typed reason, which we render below.
No rejections yet β drop a file above to trigger one.
Implementation
02<script lang="ts">
import UploadDropzone from '$lib/components/UploadDropzone.svelte';
</script>
β
<UploadDropzone
accept="image/*,.pdf"
maxFiles={5}
maxSize={5 * 1024 * 1024}
onFilesAdded={(items) => console.log('accepted', items)}
/>UploadDropzone keeps file state outside the network: it accepts drag/drop, paste, and browse, validates type/size/count, and emits typed callbacks (onFilesAdded, onFilesRejected, onRemove, onRetry) so your code owns the actual upload. The component renders preview rows with progress, success, and error states which the parent drives by feeding back UploadDropzoneItem updates.
Logic explainer
03What Does It Do? (Plain English)
A typed file-upload surface that accepts files three ways β drag-and-drop onto the panel, click-to-browse via the native file picker, or paste from the clipboard. It validates count, size, and accepted MIME types up front, then renders a typed list with image previews, progress bars, and per-row remove and retry actions. It owns local display state by default and emits callback hooks so the parent app can persist files, run uploads, or replace items with server-backed status.
Think of it as the "deposit box" for your upload pipeline: the box itself doesn't talk to the bank, it just holds the envelopes, checks they're addressed correctly, and rings a bell when the user adds or removes one. Wiring up the actual upload (XHR, fetch, presigned S3, tRPC mutation) is the consumer's job β this component handles the messy human part.
How It Works (Pseudo-Code)
state:
internalItems[] = uncontrolled file list, used when `files` prop is omitted
isDragging = true while the cursor is over the panel
dragDepth = nesting counter for nested dragenter/dragleave events
liveMessage = SR-only string for aria-live announcements
createdPreviewUrls = Set of object URLs we created (for cleanup on destroy)
derive items = controlledFiles ?? internalItems // controlled-or-uncontrolled
events:
on click panel (or Enter/Space β it's a real <button>):
if disabled or at limit: return
open native file picker via inputEl.click()
on drag enter: dragDepth++; isDragging = true
on drag over: preventDefault; dropEffect = 'copy' or 'none' (at-limit)
on drag leave: dragDepth--; isDragging = dragDepth > 0
on drop: preventDefault; addFiles(dataTransfer.files)
on paste: if clipboardData.files: preventDefault; addFiles(...)
addFiles(fileList):
for each file:
if no slots left: rejection { reason: 'count' }
else if accept rule fails: rejection { reason: 'type' }
else if file.size > maxSize: rejection { reason: 'size' }
else: build UploadDropzoneItem (with preview URL for images)
commit accepted; fire onFilesAdded; fire onFilesRejected
liveMessage announces the result for screen readers
removeItem(item):
revoke item.previewUrl (free memory)
commit items minus this one
fire onRemove + onChange
retryItem(item):
fire onRetry β parent restarts the upload, leaves the row in placeThe component is uncontrolled by default: omit files and it manages its own list. Pass files to take over β controlledFiles ?? internalItems makes the switch transparent at the derived level.
The Core Concept: Drag-Depth Counter
Drag-and-drop on a parent element is haunted by nested elements. As the cursor moves from the panel onto a child (the icon, the title, the button), the browser fires dragleave on the parent and dragenter on the child β even though the cursor is still inside the panel. A naΓ―ve isDragging = true on enter, false on leave would flicker every time the user moved across the icon.
The fix is a depth counter:
on dragenter: dragDepth += 1
on dragleave: dragDepth = max(0, dragDepth - 1)
on drop: dragDepth = 0
isDragging = dragDepth > 0Each dragenter increments. Each dragleave decrements. The cursor can only truly leave the panel when the counter hits zero β by which point every nested dragenter has been balanced by a dragleave. Hovering across the inner icon increments-then-decrements before the parent's leave fires, so isDragging stays true throughout.
The Math.max(0, ...) guard is paranoia: if the browser drops a dragleave event (Safari has been known to), the counter stays non-negative and the flag eventually resets when the user starts a new drag.
File Validation Strategy
The accept-rule matcher implements the same three-grammar shape that <input accept> uses:
".pdf" β matches any filename ending in .pdf
"image/*" β matches any MIME type starting with "image/"
"application/pdf" β exact MIME matchvalidateFileType(file):
for each rule in accept.split(','):
if rule starts with '.': filename ends with rule? β match
if rule ends with '/*': fileType starts with prefix? β match
else: fileType === rule? β match
return null on match, error message on mismatchThree rejection reasons β 'count', 'type', 'size' β are deliberately machine-readable so consumers can branch on them in onFilesRejected. Each rejection also carries a human message ready to surface in a toast.
The size check is byte-accurate: maxSize defaults to 10 MB (10 * 1024 * 1024). Files over the limit are rejected with the formatted size in the message ("foo.pdf is larger than 10 MB.") so users see exactly what budget they overran.
Memory Management: Preview URLs
Image previews use URL.createObjectURL(file), which allocates a blob URL backed by the file's bytes. These URLs leak memory if not revoked β the browser keeps the file alive in the page's heap until the URL is freed.
The component tracks every URL it creates in a SvelteSet:
on createUploadItem (image only): createdPreviewUrls.add(url)
on removeItem: URL.revokeObjectURL(url); set.delete(url)
on onDestroy: revoke every URL still in the setThe onDestroy cleanup is the safety net: if the parent unmounts the component without removing items first (e.g. user navigates away), every preview URL is revoked in one pass.
State Flow Diagram
βββββββββββββββββββββββββββ
β IDLE β
β items = [] β
β isDragging = false β
βββββββββββββ¬ββββββββββββββ
β
βββββββββββββββββΌβββββββββββββββββ
β β β
drag over click panel paste files
(dragDepth>0) β file picker (clipboard)
β β β
βΌ βΌ βΌ
ββββββββββββββββββββββββββ
β ADDING β
β validate each file β
β accept / type / size β
βββββββββββββ¬βββββββββββββ
β
ββββββββββββββ΄βββββββββββββ
β β
accepted β₯ 1 rejected β₯ 1
β β
βΌ βΌ
ββββββββββββββββ βββββββββββββββββββ
β APPEND items β β onFilesRejected β
β onFilesAdded β β liveMessage β
β onChange β βββββββββββββββββββ
ββββββββ¬ββββββββ
β
βΌ
ββββββββββββββββββ user clicks Γ βββββββββββββββ
β POPULATED β ββββββββββββββββββββββββΆβ REMOVING β
β items.length β β revoke URL β
β > 0 β ββββββββββββββββββββββββββ onRemove β
ββββββββββββββββββ user clicks retry βββββββββββββββ
β
βΌ
onRetry fires β
parent restarts uploadProps Reference
| Prop | Type | Default | Description |
|---|---|---|---|
files |
UploadDropzoneItem[] |
undefined |
Controlled file list. Omit for uncontrolled (component manages its own list). |
accept |
string |
'' |
File-accept rules; same grammar as native <input accept>. Empty string allows any type. |
multiple |
boolean |
true |
Allow multiple files per selection. When false, each pick is capped at 1. |
maxFiles |
number |
8 |
Maximum files in the list. Excess are rejected with reason 'count'. |
maxSize |
number |
10485760 |
Maximum bytes per file (10 MB). Larger files rejected with reason 'size'. |
disabled |
boolean |
false |
Disable add / remove / retry controls. |
title |
string |
'Drop files here' |
Primary heading on the surface. |
description |
string |
upload instructions | Supporting copy under the title. |
browseLabel |
string |
'Browse files' |
Browse-action button text. |
emptyLabel |
string |
'No files selected' |
Empty-list placeholder. |
class |
string |
'' |
Extra classes on the wrapper. |
fileItem |
Snippet<[UploadDropzoneItem]> |
undefined |
Optional custom row renderer. |
onChange |
(items) => void |
undefined |
Fires whenever local items change. |
onFilesAdded |
(items) => void |
undefined |
Fires with accepted files only. |
onFilesRejected |
(rejections) => void |
undefined |
Fires with rejected files + reason + message. |
onRemove |
(item) => void |
undefined |
Fires when a row's Γ is clicked. |
onRetry |
(item) => void |
undefined |
Fires when retry is clicked on an errored row. |
Edge Cases
| Situation | Behaviour |
|---|---|
User drops 12 files into a maxFiles=8 empty zone |
First 8 accepted; remaining 4 rejected with reason 'count'. onFilesAdded and onFilesRejected both fire. |
| User pastes an image from the clipboard while a text input is focused inside the page | Paste handler is on the panel <button>; it only fires when the panel itself is focused. Other paste targets are unaffected. |
| User drags a folder (not a file) | The browser flattens the folder into its top-level files via dataTransfer.files. Subdirectories are silently dropped β folders aren't supported by the spec. |
accept="image/*,.pdf" and user uploads a JPEG |
image/* matches image/jpeg; accepted. |
| User removes the last file and re-adds it | The previous preview URL was revoked on remove; a new one is created on add. No leak, no stale image. |
| Component unmounts while preview URLs are alive | onDestroy revokes every URL in createdPreviewUrls before the DOM is torn down. |
User has prefers-reduced-motion: reduce |
The hover-lift transform and progress-bar transition are disabled; state changes are instant. |
| Drag enters a nested element inside the panel | dragDepth counter prevents the flicker; isDragging stays true until the cursor truly leaves the panel. |
crypto.randomUUID() unavailable (older browser) |
Falls back to a timestamp + Math.random() slug β collision-resistant enough for client-side row IDs. |
Dependencies
- Svelte 5.x β runes (
$state,$derived,$props), snippets (fileItem), andonDestroyfor preview-URL cleanup. svelte/reactivityβSvelteSetfor the preview-URL tracker (reactive Set so cleanup is observable in tests).$lib/utils(cn) β class-name merge helper. Trivial dep, swap for plain template literal if porting.- Zero other external dependencies. Native drag-and-drop, native clipboard, native file input, scoped CSS.
File Structure
src/lib/components/UploadDropzone.svelte # implementation
src/lib/components/UploadDropzone.md # this file (rendered inside ComponentPageShell)
src/lib/components/UploadDropzone.test.ts # vitest unit tests
src/routes/uploaddropzone/+page.svelte # demo page
src/lib/types.ts # UploadDropzoneItem + UploadDropzoneProps + UploadDropzoneRejectionAPI
04| Prop | Type | Default | Description |
|---|---|---|---|
files | UploadDropzoneItem[] | β | Optional controlled list. Omit to let the dropzone manage its own state. |
accept | string | '' | MIME types and extensions the input will allow (e.g. image/*,.pdf). |
multiple | boolean | true | Allow selecting more than one file at a time. |
maxFiles | number | 8 | Cap the total number of files in the list. |
maxSize | number | 10 * 1024 * 1024 | Per-file size limit in bytes. |
disabled | boolean | false | Block all input paths. |
title / description | string | Sensible defaults | Headline and helper copy shown in the dropzone. |
browseLabel / emptyLabel | string | 'Browse files' / 'No files selected' | Button label and empty-state text. |
onChange | (items) => void | β | Fires whenever the file list changes (added, removed, or retried). |
onFilesAdded | (items) => void | β | Fires when valid files are added. |
onFilesRejected | (rejections) => void | β | Fires when files fail validation, with reasons. |
onRemove / onRetry | (item) => void | β | Row actions surfaced for each file. |