File Upload

Free

Drag-and-drop and button-style file uploaders with type/size constraints and progress.

</>sv-file-upload uploadfiledropzone
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 demo

More examples

Compact button trigger

example.htmlAngular
<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.

example.htmlAngular
<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.

example.htmlAngular
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.

example.htmlAngular
<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.

example.htmlAngular
<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.

example.htmlAngular
<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.

example.htmlAngular
// 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.

example.htmlAngular
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

NameTypeDefaultDescription
variantFileUploadVariant'button'Trigger style: button or drag-and-drop dropzone.
sizeFileUploadSize'md'Control size: sm, md or lg.
acceptstring''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.
multiplebooleantrueAllow selecting multiple files.
maxFilesnumber10Maximum number of files.
maxSizeMbnumber10Per-file size limit in MB.
disabledbooleanfalseDisable the uploader.
uploaderSvUploader | nullnullThe function that actually sends a file. Without one the component only collects files — it has no way to store them, and `autoUpload` does nothing.
autoUploadbooleantrueStart uploading as soon as files are added. Set false to collect first and call `uploadAll()` from a submit button.
concurrencynumber3Maximum 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.
demoModebooleanfalseFake 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.
labelstring'Upload files'Trigger/label text.
hintstring''Helper text shown under the trigger.
customClassstring''Extra CSS classes for the root.
errorMessagestring''Client-side validation message.
serverErrorSvServerErrornullA rejection reported by the server about this value. Self-expiring.

Outputs

EventPayload typeDescription
filesChangeUploadFile[]Emitted with the full file list whenever it changes.
fileAddedUploadFileEmitted when a single file is added.
fileRemovedstringEmitted with the id of a removed file.
uploadCompleteUploadFile[]The run has settled: every file finished uploading or failed. Fires once per run with the full file list.
uploadErrorUploadFileEmitted when a file fails validation or upload.
uploadCancelledUploadFileA file's upload was cancelled by the user.
scanChangeUploadFileA 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

NameTypeDefaultDescription
variantFileUploadVariant'dropzone'Layout of the trigger: a large drag-and-drop zone, a single-row compact zone, or a plain button.
sizeFileUploadSize'md'Padding, icon and text size of the dropzone.
acceptstring''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.
multiplebooleantrueAllow several files per pick or drop; when false only the first file of each is kept.
maxFilesnumber10Maximum number of files the queue may hold; further files are ignored.
maxSizeMbnumber10Per-file size limit in megabytes; a larger file is added in the error state instead of uploading.
disabledbooleanfalseDisables picking, dropping, pasting and removing files. Also set by a disabled parent form control.
scanPolicySvScanPolicy'none'Whether a malware verdict is required before a file counts as finished.
uploaderSvUploader | nullnullThe function that actually sends a file. Without one the component only collects files — it has no way to store them, and `autoUpload` does nothing.
autoUploadbooleantrueStart uploading as soon as files are added. Set false to collect first and call `uploadAll()` from a submit button.
concurrencynumber3Maximum 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.
demoModebooleanfalseFake 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.
labelstring'Upload files'Trigger text: the button label, the compact zone's heading, and the dropzone's accessible name.
hintstring''Helper text under the trigger. When empty the zones show the size, type and count limits instead.
customClassstring''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.
directorybooleanfalseLet 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.
maxScannedFilesnumber10_000Cap 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.
cropImagesbooleanfalseHold 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.
cropTypeSvCropImageType | '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`.
errorMessagestring''Client-side validation message.
serverErrorSvServerErrornullA rejection reported by the server about this value. Self-expiring.

Outputs

EventPayload typeDescription
filesChangeUploadFile[]Files were added, removed or cleared, or one finished uploading, was cropped or got a scan verdict. Carries every entry, including scan-blocked ones.
fileAddedUploadFileA file was added to the queue. Fires once per file, including files that failed validation.
fileRemovedstringA file was removed from the queue. Carries the removed entry's `id`.
uploadCompleteUploadFile[]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.
uploadErrorUploadFileA file failed: too large, rejected by `accept`, or its upload errored. Carries the entry with `errorMsg` set.
uploadCancelledUploadFileA file's upload was cancelled by the user.
scanChangeUploadFileA 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.
cropRequestedUploadFileA 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.

Loads giscus.app and GitHub — nothing is fetched until you click.