File Upload
FreeDrag-and-drop and button-style file uploaders with type/size constraints and progress.
import { SvFileUploadComponent } from '@styloviz/file-upload';Demo & usage
Basic usage
Usage example — open the gallery for the full interactive demo.
An interactive demo for this component lives in the gallery.
Open live demoMore examples
Compact button trigger
<sv-file-upload variant="button" size="sm" accept=".pdf,.docx" (fileAdded)="add($event)" />
Validation events
accept and maxSizeMb are enforced whenever a file is added; rejected files surface through uploadError. The dropzone/compact variants shown here also enforce it on drag-and-drop, but they ship in the Pro package — the free tier's button variant enforces the same picker-side validation.
<sv-file-upload-pro variant="dropzone" accept="image/*" [maxSizeMb]="5" (filesChange)="onFiles($event)" (uploadError)="onReject($event)" />
Chunked and resumable uploads (Pro)
A factory that builds an uploader, because resumability is a property of the transport. Chunk planning, resume from a server-reported offset, retryable-status policy and full-jitter backoff are exported separately for hosts with their own transport.
import { svChunkedUploader } from '@styloviz-pro/file-upload'; upload = svChunkedUploader({ // How many bytes the server already has. Optional; a failure here just means // starting from the beginning rather than failing the upload. probe: ({ key, signal }) => fetch(`/api/uploads/${key}`, { signal }) .then(r => (r.ok ? r.json().then(j => j.bytes) : 0)), sendChunk: ({ key, blob, chunk, signal }) => fetch(`/api/uploads/${key}`, { method: 'PATCH', headers: { 'Upload-Offset': String(chunk.start) }, body: blob, signal, }).then(r => { // Attach the status so the retry policy can read it: a 503 is retried, // a 413 is not — retrying a body that is over the limit uploads it in // full, five times, to fail identically every time. if (!r.ok) throw Object.assign(new Error(r.statusText), { status: r.status }); }), complete: ({ key, signal }) => fetch(`/api/uploads/${key}/complete`, { method: 'POST', signal }).then(r => r.json()), }); <sv-file-upload-pro [uploader]="upload" [maxSizeMb]="5000" />
Paste to upload
Off by default. `page` listens document-wide and ignores any paste aimed at an input, textarea or contenteditable — that is how an upload zone avoids stealing a paste meant for the comment box beside it. `zone` only counts a paste made with focus inside the dropzone. A paste carrying only text is never touched.
<sv-file-upload-pro pasteToUpload="page" accept="image/*" /> // Every pasted screenshot arrives named image.png, so three pastes would // collide on the server. They are renamed from the clock and their ordinal: // pasted-2026-08-17-101530.png // pasted-2026-08-17-101530-2.png
Folder upload
`directory` sets webkitdirectory on the picker. Dropped folders are expanded whichever way it is set, because dataTransfer.files reports a directory as a zero-byte File — reading it queued the folder itself as a broken upload.
<sv-file-upload-pro [directory]="true" accept="image/*" [maxScannedFiles]="20000" (intakeSkipped)="notice.set($event)" /> // Each file keeps the path it had inside the folder, so three folders each // holding an IMG_0001.jpg stay distinguishable. files()[0].path; // 'trip/day1/IMG_0001.jpg' // intakeSkipped reports what was left behind — .DS_Store, the accept filter, // or the scan limit. A folder drop that quietly loses files is // indistinguishable from a broken component.
Inline crop
`cropImages` holds croppable images instead of sending them. It is a distinct **status**, not a flag on a pending file — `uploadAll()` sends everything pending, so the flag version uploads the un-cropped original while the crop dialog is still open. file-upload takes no dependency on image-editor: bind `<sv-image-cropper>` to `cropQueue()` and hand the blob back with `applyCrop()`. The result is re-validated, because a lossless PNG re-encode of a lossy JPEG routinely doubles the file — the crop that removed pixels is what pushes it past the size limit.
<sv-file-upload-pro #upload accept="image/*" [cropImages]="true" /> @let held = upload.cropQueue()[0]; @if (held && held.preview) { <sv-image-cropper #cropper [src]="held.preview" [aspect]="1" /> <button (click)="upload.applyCrop(held.id, await cropper.cropImage())">Crop</button> <button (click)="upload.skipCrop(held.id)">Use the original</button> } // canvas.toBlob falls back to image/png when it cannot write the type it was // handed — silently, with no error and no flag. The blob's own type therefore // wins over the request, or photo.webp ends up full of PNG bytes. svCropReplacement(original, pngBlob, { type: 'image/webp' }).name; // 'photo.png' // HEIC, TIFF and SVG are never offered a cropper: an <img> pointed at an HEIC // outside Safari never fires load, so the cropper sits blank forever with no // error to react to. They upload untouched. svIsCroppableImage({ type: 'image/heic' }); // false
Reactive forms (formControlName)
Opt-in ControlValueAccessor: the control value is the native File[], so the field binds to reactive and template-driven forms while keeping its event API. formControl.disable() disables the dropzone and row actions.
// form = new FormGroup({ docs: new FormControl<File[]>([]) }); <sv-file-upload formControlName="docs" accept=".pdf" /> <!-- or template-driven --> <sv-file-upload [(ngModel)]="docs" />
Resume across a page reload (Pro)
svChunkedUploader resumes from whatever offset the probe reports; svLocalResumeStore remembers WHICH upload session a file belongs to, so the probe still has something to ask about after a reload. Scope it to the account — an upload URL is a capability — and check svCanResume before trusting a record.
import { svChunkedUploader, svLocalResumeStore, svCanResume } from '@styloviz-pro/file-upload'; const store = svLocalResumeStore({ scope: currentUser.id }); store.prune(24 * 60 * 60 * 1000); uploader = svChunkedUploader({ probe: async ({ file, key, signal }) => { const saved = store.get(key); if (svCanResume(saved, file)) { const res = await fetch(saved!.uploadUrl, { method: 'HEAD', signal }); if (res.ok) return Number(res.headers.get('Upload-Offset') ?? 0); } const created = await fetch('/api/uploads', { method: 'POST', signal }); store.set({ key, uploadUrl: created.headers.get('Location')!, size: file.size, lastModified: file.lastModified, createdAt: Date.now() }); return 0; }, sendChunk: ({ key, blob, chunk, signal }) => fetch(store.get(key)!.uploadUrl, { method: 'PATCH', body: blob, signal, headers: { 'Upload-Offset': String(chunk.start) } }).then((r) => { if (!r.ok) throw Object.assign(new Error(r.statusText), { status: r.status }); }), complete: async ({ key }) => { store.remove(key); }, });
When to use
- Letting users attach files via drag-and-drop or a button trigger.
- You need type/size constraints and per-file progress out of the box.
- Use the dropzone variant for media-heavy flows, the button variant in tight toolbars.
Async states
None — this component has nothing to load, so it has no loading, empty or error state to drive.
Inputs
| Name | Type | Default | Description |
|---|---|---|---|
| variant | FileUploadVariant | 'button' | Trigger style: button or drag-and-drop dropzone. |
| size | FileUploadSize | 'md' | Control size: sm, md or lg. |
| accept | string | '' | Accepted file types — which files the picker offers and the queue keeps — `"image/*,.pdf"`. THIS IS A CONVENIENCE FILTER. IT IS NOT A SECURITY CHECK. It matches an extension out of a filename the user chose, or a `File.type` the browser derived from that same extension — so `payload.exe` renamed `photo.png` passes `accept=".png"` and reports `image/png`, because it is called `.png`. Nothing here reads the bytes. AND THE FILE NEED NOT COME THROUGH THIS COMPONENT AT ALL. Your upload endpoint accepts requests; `curl` is not running this filter. Every check here runs in a browser belonging to a user who is not the attacker in the case that matters. What it is genuinely for: keeping the OS picker tidy and keeping junk out of the queue. Both are about the honest user's experience, which is the whole job. ⚠️ **Validate the type on the server, from the bytes, at the point of storage** — with a size limit the client cannot raise and a malware scan. `markScanned` is how the verdict gets back here. |
| multiple | boolean | true | Allow selecting multiple files. |
| maxFiles | number | 10 | Maximum number of files. |
| maxSizeMb | number | 10 | Per-file size limit in MB. |
| disabled | boolean | false | Disable the uploader. |
| uploader | SvUploader | null | null | The function that actually sends a file. Without one the component only collects files — it has no way to store them, and `autoUpload` does nothing. |
| autoUpload | boolean | true | Start uploading as soon as files are added. Set false to collect first and call `uploadAll()` from a submit button. |
| concurrency | number | 3 | Maximum uploads in flight at once. The rest queue. Firing ten parallel uploads saturates the connection and makes every one of them slower. Clamped to at least 1. |
| demoMode | boolean | false | Fake the upload with a progress animation and an occasional random failure. For demos and screenshots only — never in a real app. Replaces the former `autoSimulate` input, and is ignored whenever an `uploader` is supplied. |
| label | string | 'Upload files' | Trigger/label text. |
| hint | string | '' | Helper text shown under the trigger. |
| customClass | string | '' | Extra CSS classes for the root. |
| errorMessage | string | '' | Client-side validation message. |
| serverError | SvServerError | null | A rejection reported by the server about this value. Self-expiring. |
Outputs
| Event | Payload type | Description |
|---|---|---|
| filesChange | UploadFile[] | Emitted with the full file list whenever it changes. |
| fileAdded | UploadFile | Emitted when a single file is added. |
| fileRemoved | string | Emitted with the id of a removed file. |
| uploadComplete | UploadFile[] | The run has settled: every file finished uploading or failed. Fires once per run with the full file list. |
| uploadError | UploadFile | Emitted when a file fails validation or upload. |
| uploadCancelled | UploadFile | A file's upload was cancelled by the user. |
| scanChange | UploadFile | A file's malware-scan verdict changed. Carries the whole entry. Fires for every transition the host drives, `'pending'` included, so a toast or an audit line can be written when it happens rather than by diffing `files()`. |
<sv-file-upload-pro>
Also in this family — SvFileUploadProComponent from @styloviz-pro/file-upload.
Async states
None — this component has nothing to load, so it has no loading, empty or error state to drive.
Inputs
| Name | Type | Default | Description |
|---|---|---|---|
| variant | FileUploadVariant | 'dropzone' | Layout of the trigger: a large drag-and-drop zone, a single-row compact zone, or a plain button. |
| size | FileUploadSize | 'md' | Padding, icon and text size of the dropzone. |
| accept | string | '' | Which files the picker offers and the queue keeps — `"image/*,.pdf"`. THIS IS A CONVENIENCE FILTER. IT IS NOT A SECURITY CHECK, AND NOTHING IT DOES SURVIVES CONTACT WITH SOMEONE WHO MEANS IT. It matches on two things and an attacker controls both. The extension is part of a filename they chose — `payload.exe` renamed `photo.png` passes `accept=".png"` and always will. And `File.type` is not read from the bytes: the browser derives it from that same extension, so a renamed executable reports `image/png` because it is called `.png`. A `Blob` constructed in a script can claim any type at all. MORE TO THE POINT, THE FILE NEED NOT COME THROUGH THIS COMPONENT. Your upload endpoint accepts requests, and `curl` is not running this filter. Every check here runs in a browser belonging to a user who is not the attacker in the case that matters. WHAT IT IS GENUINELY FOR: keeping the OS picker tidy, and keeping `.DS_Store` out of a five-thousand-file folder drop. Both are real and both are about the honest user's experience, which is the whole job. ⚠️ **Validate the type on the server, from the bytes, at the point of storage.** Content sniffing, a size limit the client cannot raise, and a malware scan. `scanPolicy` below is how the verdict gets back here. |
| multiple | boolean | true | Allow several files per pick or drop; when false only the first file of each is kept. |
| maxFiles | number | 10 | Maximum number of files the queue may hold; further files are ignored. |
| maxSizeMb | number | 10 | Per-file size limit in megabytes; a larger file is added in the error state instead of uploading. |
| disabled | boolean | false | Disables picking, dropping, pasting and removing files. Also set by a disabled parent form control. |
| scanPolicy | SvScanPolicy | 'none' | Whether a malware verdict is required before a file counts as finished. |
| uploader | SvUploader | null | null | The function that actually sends a file. Without one the component only collects files — it has no way to store them, and `autoUpload` does nothing. |
| autoUpload | boolean | true | Start uploading as soon as files are added. Set false to collect first and call `uploadAll()` from a submit button. |
| concurrency | number | 3 | Maximum uploads in flight at once. The rest queue. Firing ten parallel uploads saturates the connection and makes every one of them slower. Clamped to at least 1. |
| demoMode | boolean | false | Fake the upload with a progress animation and an occasional random failure. For demos and screenshots only — never in a real app. Replaces the former `autoSimulate` input, and is ignored whenever an `uploader` is supplied. |
| label | string | 'Upload files' | Trigger text: the button label, the compact zone's heading, and the dropzone's accessible name. |
| hint | string | '' | Helper text under the trigger. When empty the zones show the size, type and count limits instead. |
| customClass | string | '' | Extra CSS classes added to the root wrapper. |
| pasteToUpload | 'off' | 'zone' | 'page' | 'off' | Accept files pasted from the clipboard. THE DEFAULT IS `'off'`, AND `'page'` IS THE ONE TO THINK ABOUT BEFORE TURNING ON. A document-level paste listener is what makes "just paste a screenshot" work, and it is also how an upload zone steals a paste intended for the comment box next to it. `'page'` therefore ignores any paste whose target is an editable element — an input, a textarea, a contenteditable — because in that case the user was demonstrably typing somewhere else. `'zone'` is the conservative option: the paste only counts when focus is inside the dropzone. |
| directory | boolean | false | Let the user pick a whole folder, and expand folders that are dropped. Sets `webkitdirectory` on the picker. Folder DROPS are expanded whichever way this is set — see `onDrop` for why that is not a choice. |
| maxScannedFiles | number | 10_000 | Cap on how many files one folder drop may add, before `maxFiles` applies. A SEPARATE, MUCH LARGER NUMBER THAN `maxFiles` ON PURPOSE. This one exists to stop a home directory dropped by accident from locking the tab while it walks a hundred thousand entries; `maxFiles` is a product rule about how many the user may submit. Conflating them means the walk keeps going long after the answer stopped mattering. |
| cropImages | boolean | false | Hold croppable images back so the user can crop them before they upload. THIS COMPONENT DOES NOT RENDER A CROPPER, AND WILL NOT. `file-upload` takes no dependency on `@styloviz-pro/image-editor`: a host that wants inline crop composes `<sv-image-cropper>` itself against `cropQueue()` and calls `applyCrop()` with the blob, and a host that does not should not ship a canvas cropper it never renders. The seam is a `Blob`, which is all an uploader ever needed. Only types a canvas can actually round-trip are held — see `svIsCroppableImage`, where every exclusion is a different silent failure. Anything else queues normally, so turning this on can never strand a file. |
| cropType | SvCropImageType | 'auto' | 'auto' | The type crops are written as. `'auto'` follows the source, which is the only setting that neither inflates a photo into a lossless PNG nor composites a transparent logo onto black. See `svCropOutputType`. |
| errorMessage | string | '' | Client-side validation message. |
| serverError | SvServerError | null | A rejection reported by the server about this value. Self-expiring. |
Outputs
| Event | Payload type | Description |
|---|---|---|
| filesChange | UploadFile[] | Files were added, removed or cleared, or one finished uploading, was cropped or got a scan verdict. Carries every entry, including scan-blocked ones. |
| fileAdded | UploadFile | A file was added to the queue. Fires once per file, including files that failed validation. |
| fileRemoved | string | A file was removed from the queue. Carries the removed entry's `id`. |
| uploadComplete | UploadFile[] | The run has settled: every file finished uploading or failed and, under `scanPolicy="required"`, every uploaded file has a verdict (`'clean'` or `'flagged'`). Fires once per run with the full list — read each entry's `status` and `scan`, since a flagged file is in it. |
| uploadError | UploadFile | A file failed: too large, rejected by `accept`, or its upload errored. Carries the entry with `errorMsg` set. |
| uploadCancelled | UploadFile | A file's upload was cancelled by the user. |
| scanChange | UploadFile | A file's malware-scan verdict changed. Carries the whole entry. Fires for every transition the host drives, `'pending'` included, so a toast or an audit line can be written at the moment it happens rather than by diffing `files()`. |
| intakeSkipped | { skipped: number; truncated: boolean } | Files an intake left behind, and why — junk metadata, the `accept` filter, or the scan limit. A FOLDER DROP THAT QUIETLY LOSES FILES IS THE WORST OUTCOME. Dropping 250 photos and seeing 180 queue up, with nothing saying which 70 went or why, is indistinguishable from a broken component. This fires once per intake so the host can say so. |
| cropRequested | UploadFile | A file is waiting to be cropped. Fires once per held file, as it is added. THE ENTRY IS EMITTED BEFORE ITS `preview` EXISTS. The thumbnail is read asynchronously by a `FileReader`, so the entry handed over here has no `preview` yet — a host that pins this object as its cropper's `src` gets an empty string forever. Bind to `cropQueue()` instead, which is a signal over the live entries; this event is a notification, not a snapshot to hold on to. |
| cropApplied | { entry: UploadFile; warnings: readonly string[] } | A crop was applied, with whatever the round trip cost. |
Customization & theming
- Choose `variant` (dropzone · compact · button) and `size`; the free tier ships the button variant.
- Constrain with `accept`, `maxSizeMb`, `maxFiles` and `multiple`; wire a real backend with the `uploader` function (progress, cancel via AbortSignal, retry, concurrency limit).
- React to `filesChange`, `fileAdded`, `fileRemoved`, `uploadComplete` and `uploadError`, or bind the `File[]` value with a reactive control (CVA).
- Chunked and resumable uploads (Pro) via `svChunkedUploader`. `svPlanChunks`, `svResumePlan`, `svIsRetryableStatus`, `svRetryDelay`, `svChunkProgress` and `svUploadKey` are exported for hosts with their own transport.
- Paste-to-upload (Pro) via `pasteToUpload` (`off` · `zone` · `page`), and folder upload via `directory` and `maxScannedFiles`, with `intakeSkipped` reporting what an intake left behind. `svExpandDrop`, `svReadDirectory`, `svNamePastedFiles` and `svFilterIntake` are exported.
Best practices
- Do: Constrain `accept` and `maxSizeMb` so failures surface immediately — they are enforced on drop too.
- Don't: Don't treat `accept` as a security check. It matches an extension out of a filename the user chose, or a `File.type` the browser derived from that same extension — so `payload.exe` renamed `photo.png` passes `accept=".png"` and reports `image/png`. Nothing here reads the bytes, and `curl` is not running this filter at all. Validate the type on the server, from the bytes, at the point of storage.
- Do: Report a malware verdict with `markScanning(id)` and `markScanned(id, verdict, reason?)` if you scan. The upload succeeding and the file being safe are different facts arriving seconds apart, and without the second one the row says "Uploaded" with a green tick while the scanner is still thinking.
- Do: Set `scanPolicy="required"` (Pro) when you scan. It is what keeps an uncleared file out of the form value — the thing a submit handler actually reads — so the rest of the UI being honest is not left carrying the whole job. A flagged file is excluded under either policy.
- Don't: Don't expect a flagged file to appear in `errorCount` or offer a Retry. Its `status` stays `'success'` because the upload worked and the content is the problem; a retry would re-send bytes the server has already looked at and rejected. `flaggedCount()` is the one to read.
- Do: Reflect progress and errors per file using the `uploadComplete` / `uploadError` events.
- Do: Bind with `formControlName` / `[(ngModel)]` when you need the `File[]` as part of a form; the event API stays available for progress/validation UX.
- Do: Surface `intakeSkipped` after a folder drop. A user who drops 250 photos and sees 180 queue, with nothing saying which 70 went or why, cannot tell your filter from a bug.
- Do: Have the server verify the length of a resumed upload. `svUploadKey` is name + size + lastModified, which is a hint rather than an authority — two different files can collide, and a client-side key is not something to append bytes on the strength of.
- Don't: Don't raise `concurrency` on `svChunkedUploader` unless your endpoint takes an explicit range or part number. Append-only endpoints store parallel chunks in arrival order, and the file uploads cleanly, reports success, and is a shuffle of itself.
- Don't: Don't silently drop rejected files — the component marks them as errors so you can tell the user why.
- Do: Persist the upload handle if you want resume to survive a reload. `svResumePlan` works out what is left to send GIVEN a server offset — but asking the server for that offset means knowing which upload to ask about, and that handle used to live only in a JavaScript variable. `svLocalResumeStore` writes it down; `svCanResume` checks a stored record against the file in hand before you trust it.
- Don't: Don't use the resume store without a `scope`. An upload URL is a capability, and `localStorage` is scoped to an ORIGIN rather than to a user — on a shared machine, or after a logout that does not clear storage, the next person can resume the previous person's upload. Pass the account id. It is not encryption; it is the difference between "anybody on this browser" and "this account on this browser".
- Do: Call `prune()` on startup. A record is written per upload and removed on completion, so abandoned uploads leave theirs behind forever — and `localStorage` is a few megabytes shared with everything else your origin stores.
Accessibility
- Focus after a mutation (X-12): removing a file moves focus to the Remove button of the file that took its place, then the one before it, then the control that adds files; Clear all goes to that control. A host calling `removeFile()` programmatically does not move focus.
- Keyboard-accessible `role="button"` dropzone (Enter/Space opens the dialog) with a visible focus ring; disabled removes it from the tab order and disables the row actions.
- File add/remove/upload status is announced through a visually-hidden `aria-live="polite"` region; progress bars expose `role="progressbar"` with `aria-valuenow`/`min`/`max`.
- The `accept` filter is enforced on drag-and-drop as well as the picker — non-matching files are flagged as errors rather than accepted.
- Paste-to-upload never swallows a text paste and never calls preventDefault on one, so copying a URL and pressing Ctrl+V on a page that happens to contain an upload zone keeps working. In `page` mode a paste aimed at an editable element is ignored outright.
- Each file row exposes labelled retry/remove controls.
- Implements ControlValueAccessor — the value is `File[]`; works with `[(ngModel)]`, `[formControl]` and `formControlName`, and `formControl.disable()` disables the field.
Feedback on File Upload
Spotted a gap in this page, or something that didn't work as described? Comments are GitHub Discussions in the public styloviz/community repo, so posting needs a GitHub account.