Angular Material has no built-in file upload component. You build one from mat-button, a hidden file input, mat-list and mat-progress-bar, and use a client like filestack-js to send the files. This guide adds scanning and reading states to every row.
A user of your expense app drags four files into the upload list: a receipt, an invoice, a PDF and a photo. Every progress bar fills, and Submit still says the files arenât ready. The list has nothing more to show, because the upload finished and the work that follows it hasnât.
This Angular Material file upload shows that work. Two Filestack features run on every file in a workflow attached at upload: Virus Detection checks the file for malware, and OCR reads the text out of every clean image. We built a mat-list queue around them, with a Node server that signs the upload and reports each fileâs workflow status, so every row moves through Uploading, Scanning, Reading and Ready, or stops at Rejected. The upload runs on filestack-js, the client under our Angular file upload SDK, so Angular Material draws the list.
Key takeaways
- The upload promise resolves when the file is stored. The scan and the text extraction run after it, in a workflow attached through the
workflowsupload option. file.workflowsholds one entry per workflow, keyed by workflow ID, and itsjobidis what the app polls.- The job reads
InProgressuntil both tasks finish, andresultsgains each taskâs output as it completes. - The OCR task runs only when the scan reports
infectedasfalse, so an infected file is never read. - The scan leaves an infected file in storage, so the server deletes it and the list blocks Submit until the row is removed.
Why an Angular Material file upload at 100% isnât done
A progress bar measures bytes sent. When the last byte lands, filestack-js resolves the upload with the stored file, and a workflow attached to that upload starts on it. In our example, the virus scan takes about ten seconds per file, small receipt or 12 MB photo alike, and OCR follows it. An Angular Material file upload that stops at 100% leaves those seconds blank, and Submit has nothing to wait on.
There are four common ways to cover that gap:
| Option | What you run | Where it fits |
|---|---|---|
| Accept at 100%, scan later | A queue, ClamAV and Tesseract on your servers, and a way to pull a file back after the user moved on | Files nobody acts on right away |
| Scan inside your upload endpoint | Your API receives the file, scans it and reads it before answering | Small files, and an API that can hold the request open |
| A cloud storage trigger | S3 events into Amazon GuardDuty Malware Protection for S3 and Textract, and status written to your database | An app already built on AWS |
| A Filestack workflow at upload | One workflow with Virus Detection and OCR, and a status call per file | You want the scan, the text and a status for each file without running either engine |
The rest of this post builds the last row.
Create the scan and read workflow
The workflow is two tasks, the scan first and the text extraction second, with a condition on the second. Itâs one of our file processing workflows, built in the console at console.filestack.com:
- Open Workflows, click Create New and select Build from scratch.
- Click Select the first task, open the Intelligence tab and pick
virus_detection. Name itvirus_scanand click Save. - Click the add button under
virus_scan, open Intelligence and pickocr. Name itocr. - Click Add new condition, type
infectedin Path, leave Equal to, typefalsein Value and click Save. - Name the workflow and copy its Workflow Id.
Under the editor, the console sums it up as âScan for viruses, if âinfectedâ equals âfalseâ then apply ocrâ:
The task names are the keys each result arrives under, so the server reads results.virus_scan and results.ocr. The verdict is an infected boolean and an infections_list of detection names. The virus detection setup post covers running the same scan on files you already store.
OCR reads images. For a PDF, the ocr entry carries {"error": "unsupported mimetype"} and the job still finishes, so a clean PDF reaches Ready without text. The OCR response reference describes the blocks, lines and words, each with a four-point bounding_box, that sit next to the flat text field.
Sign the upload and report job status on the server
Turn on application security under Configuration, Security, Policy & Signature in the console, and the same card shows the app secret. The server signs the browserâs upload policy and every status call with it, so the secret never reaches the page. Our Node server has no dependencies and needs Node 20.6 or later for --env-file. .env:
FILESTACK_API_KEY=YOUR_API_KEY
FILESTACK_APP_SECRET=YOUR_APP_SECRET # Configuration > Security > Policy & Signature
FILESTACK_WORKFLOW_ID=YOUR_WORKFLOW_ID # the scan and read workflow
server.mjs answers two routes. /api/upload-config returns a policy for the browser, and /api/jobs/:jobid turns a workflow status into the five fields the list needs:
// Signs upload policies and reports each file's workflow status to the Angular app.
import { createServer } from 'node:http';
import { createHmac } from 'node:crypto';
const { FILESTACK_API_KEY, FILESTACK_APP_SECRET, FILESTACK_WORKFLOW_ID, PORT = '3000' } = process.env;
// Sign a policy with the app secret. Keep the = padding.
function sign(policy) {
const encoded = Buffer.from(JSON.stringify(policy))
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_');
const signature = createHmac('sha256', FILESTACK_APP_SECRET).update(encoded).digest('hex');
return { policy: encoded, signature };
}
const inSeconds = (s) => Math.floor(Date.now() / 1000) + s;
function uploadConfig() {
const security = sign({
call: ['pick', 'store', 'read', 'convert', 'runWorkflow'],
expiry: inSeconds(3600),
});
return { apikey: FILESTACK_API_KEY, workflowId: FILESTACK_WORKFLOW_ID, ...security };
}
const removed = new Set();
async function deleteFile(handle) {
const { policy, signature } = sign({ call: ['remove'], handle, expiry: inSeconds(60) });
const params = new URLSearchParams({ key: FILESTACK_API_KEY, policy, signature });
const res = await fetch(`https://www.filestackapi.com/api/file/${handle}?${params}`, { method: 'DELETE' });
if (!res.ok) throw new Error(`delete ${handle}: ${res.status}`);
}
async function jobStatus(jobid) {
const { policy, signature } = sign({
call: ['read', 'convert', 'runWorkflow'],
expiry: inSeconds(300),
});
const res = await fetch(
`https://cdn.filestackcontent.com/${FILESTACK_API_KEY}/security=p:${policy},s:${signature}` +
`/workflow_status=job_id:${jobid}`,
);
if (!res.ok) throw new Error(`workflow_status ${res.status}: ${await res.text()}`);
const job = await res.json();
const scan = job.results?.virus_scan?.data;
const ocr = job.results?.ocr;
if (scan?.infected && !removed.has(jobid)) {
removed.add(jobid);
await deleteFile(job.sources[0]); // the scan leaves the file in storage
console.log(`removed ${job.sources[0]}: ${scan.infections_list.join(', ')}`);
}
return {
status: job.status, // InProgress, Finished or Failed
scanned: Boolean(scan),
infected: scan?.infected ?? false,
infections: scan?.infections_list ?? [],
text: ocr?.data?.text ?? null,
ocrError: ocr?.error ?? null,
};
}
createServer(async (req, res) => {
const send = (code, body) => {
res.writeHead(code, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(body));
};
try {
if (req.url === '/api/upload-config') return send(200, uploadConfig());
const match = req.url.match(/^\/api\/jobs\/([0-9a-f-]{36})$/);
if (match) return send(200, await jobStatus(match[1]));
send(404, { error: 'not found' });
} catch (err) {
console.error(err.message);
send(502, { error: err.message });
}
}).listen(Number(PORT), () => console.log(`API on http://localhost:${PORT}`));
The upload policy carries runWorkflow because the upload starts the workflow. The workflowâs tasks open the stored file, so it also needs read with convert. The status call signs the same three. Start it:
node --env-file=.env server.mjs
It prints:
API on http://localhost:3000
Right after an upload, the status call returns the job with no results yet:
{
"createdAt": "2026-11-09T06:54:06.000576334Z",
"jobid": "c6ac87f2-f2d4-4db0-b78a-8969ad768c28",
"sources": ["B7dfPILnQM6xwQiaLlup"],
"status": "InProgress",
"updatedAt": "2026-11-09T06:54:06.075628506Z",
"workflow": "de9ea60b-36aa-4b2e-9f68-1ed35de1ab8d"
}
Once both tasks finish, status is Finished and results holds both outputs. This is the receipt, with the OCR output trimmed to its flat text:
{
"jobid": "c6ac87f2-f2d4-4db0-b78a-8969ad768c28",
"results": {
"ocr": {
"data": {
"page_height": 1200,
"page_width": 800,
"text": "KITSUNE NOODLE BAR\nSTANDARD14 Harbour Lane, Portview\nReceipt 004817 2026-09-28 19:42\n...",
"text_area_percentage": 10.256354166666666
}
},
"virus_scan": { "data": { "infected": false, "infections_list": [] } }
},
"status": "Finished",
"ttl": 172800
}
Between the two, a job can read InProgress with only virus_scan in results. The server reports that as scanned: true, which is the Reading state.
Add Angular Material and filestack-js
The sample uses Angular 22 with Angular Material 22 and filestack-js 4.3.9. For a new project, generate it and add the two libraries:
npx @angular/cli@22 new expense-uploads --defaults --style=css --ssr=false
cd expense-uploads
npm install @angular/material @angular/cdk filestack-js
Add a Material theme to the styles array in angular.json, ahead of your own stylesheet:
"styles": [
"@angular/material/prebuilt-themes/azure-blue.css",
"src/styles.css"
]
Then send /api requests from ng serve to the Node server. proxy.conf.json:
{
"/api": { "target": "http://localhost:3000", "secure": false }
}
Point serve.options.proxyConfig at proxy.conf.json in angular.json. The Angular SDK v4 release covers the picker component, which draws its own interface. Here the Material list is the interface, so the component calls filestack-js directly, the same client our JavaScript file upload SDK documents.
Hold per-file state in signals
The queue is a standalone Angular Material file upload component. Each row is one object in a signal, and canSubmit is computed from all of them. The top of upload-queue.ts declares the six stages and what the serverâs status route returns:
import { Component, computed, signal } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatChipsModule } from '@angular/material/chips';
import { MatListModule } from '@angular/material/list';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import * as filestack from 'filestack-js';
type Stage = 'uploading' | 'scanning' | 'reading' | 'ready' | 'rejected' | 'failed';
interface QueuedFile {
id: number;
name: string;
stage: Stage;
progress: number;
detail: string;
}
interface JobStatus {
status: 'InProgress' | 'Finished' | 'Failed';
scanned: boolean;
infected: boolean;
infections: string[];
text: string | null;
ocrError: string | null;
}
const LABELS: Record<Stage, string> = {
uploading: 'Uploading',
scanning: 'Scanning',
reading: 'Reading',
ready: 'Ready',
rejected: 'Rejected',
failed: 'Failed',
};
const POLL_MS = 2000;
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
Submit stays disabled until every row is Ready, so a rejected or failed row has to be removed first.
Upload each file and poll its workflow status
The component uploads every picked file at once, moves each row to Scanning when its upload resolves, and polls that fileâs job every two seconds. The rest of upload-queue.ts:
@Component({
selector: 'app-upload-queue',
imports: [MatButtonModule, MatChipsModule, MatListModule, MatProgressBarModule],
templateUrl: './upload-queue.html',
styleUrl: './upload-queue.css',
})
export class UploadQueue {
protected readonly labels = LABELS;
protected readonly files = signal<QueuedFile[]>([]);
protected readonly canSubmit = computed(
() => this.files().length > 0 && this.files().every((f) => f.stage === 'ready'),
);
private nextId = 0;
async add(list: FileList | null | undefined) {
const picked = Array.from(list ?? []); // copy now, the input is cleared right after
if (!picked.length) return;
const config = await fetch('/api/upload-config').then((r) => r.json());
const client = filestack.init(config.apikey, {
security: { policy: config.policy, signature: config.signature },
});
for (const file of picked) this.upload(client, file, config.workflowId);
}
onDrop(event: DragEvent) {
event.preventDefault();
this.add(event.dataTransfer?.files);
}
remove(id: number) {
this.files.update((list) => list.filter((f) => f.id !== id));
}
private patch(id: number, change: Partial<QueuedFile>) {
this.files.update((list) => list.map((f) => (f.id === id ? { ...f, ...change } : f)));
}
private async upload(client: filestack.Client, file: File, workflowId: string) {
const id = this.nextId++;
this.files.update((list) => [
...list,
{ id, name: file.name, stage: 'uploading', progress: 0, detail: '' },
]);
try {
const stored = await client.upload(
file,
{ onProgress: (e: { totalPercent: number }) => this.patch(id, { progress: e.totalPercent }) },
{ filename: file.name, workflows: [workflowId] },
);
const run = stored.workflows?.[workflowId];
if (!run?.jobid) throw new Error(run?.error ?? 'The workflow did not start');
this.patch(id, { stage: 'scanning', progress: 100, detail: 'Checking for malware' });
await this.watch(id, run.jobid);
} catch (err) {
this.patch(id, { stage: 'failed', detail: err instanceof Error ? err.message : String(err) });
}
}
private async watch(id: number, jobid: string) {
for (;;) {
await sleep(POLL_MS);
const res = await fetch(`/api/jobs/${jobid}`);
if (!res.ok) continue; // try again on the next tick
const job: JobStatus = await res.json();
if (job.infected) {
return this.patch(id, { stage: 'rejected', detail: job.infections.join(', ') });
}
if (job.status === 'Failed') {
return this.patch(id, { stage: 'failed', detail: 'The workflow failed' });
}
if (job.status === 'Finished') {
const lines = job.text?.split('\n').filter(Boolean).length ?? 0;
return this.patch(id, {
stage: 'ready',
detail: job.ocrError
? `Clean, not read (${job.ocrError})`
: `Clean, ${lines} ${lines === 1 ? 'line' : 'lines'} of text read`,
});
}
if (job.scanned) this.patch(id, { stage: 'reading', detail: 'Clean, reading text' });
}
}
}
When a workflow canât start, the upload still resolves, and its workflow entry has an empty jobid and an error such as policy error: the signature has expired. upload turns that into a Failed row instead of polling a job that doesnât exist.
add copies the FileList before its first await. The template clears the input right after calling add, and that empties the same FileList object, so reading it after the policy request returns no files.
The order of the checks in watch is the order of the states. An infected verdict ends the row at Rejected whatever else the job says. Finished ends it at Ready. Otherwise, a scan result without an OCR result means Reading, and no scan result means the row stays on Scanning. With OCR that finishes inside one polling interval, a row moves from Scanning straight to Ready.
Show each state with chips and progress bars
The template draws one mat-list-item per file, a chip for the stage, and a mat-progress-bar under the row while thereâs work in progress. upload-queue.html:
<div class="drop" (dragover)="$event.preventDefault()" (drop)="onDrop($event)">
<button mat-stroked-button (click)="picker.click()">Choose files</button>
<span>or drop them here</span>
<input #picker type="file" multiple hidden (change)="add(picker.files); picker.value = ''" />
</div>
<mat-list>
@for (file of files(); track file.id) {
<mat-list-item [class]="file.stage">
<span matListItemTitle>{{ file.name }}</span>
<span matListItemLine>
{{ file.detail }}
@if (file.stage === 'rejected' || file.stage === 'failed') {
<button mat-button (click)="remove(file.id)">Remove</button>
}
</span>
<mat-chip-set matListItemMeta>
<mat-chip [disableRipple]="true">{{ labels[file.stage] }}</mat-chip>
</mat-chip-set>
</mat-list-item>
@if (file.stage === 'uploading') {
<mat-progress-bar mode="determinate" [value]="file.progress" />
} @else if (file.stage === 'scanning' || file.stage === 'reading') {
<mat-progress-bar mode="indeterminate" />
}
}
</mat-list>
<button mat-flat-button [disabled]="!canSubmit()">Submit</button>
While a file uploads, its bar fills with totalPercent. Once itâs stored, the bar switches to indeterminate, because the scan reports no percentage. upload-queue.css colors each chip by the rowâs stage class and gives the two-line rows room for the chip:
:host { display: block; max-width: 640px; margin: 32px auto; }
.drop { display: flex; gap: 12px; align-items: center; padding: 24px; border: 2px dashed #c4c7c5; border-radius: 12px; }
mat-list { margin: 16px 0; }
mat-list-item { --mat-list-list-item-two-line-container-height: 80px; }
mat-chip-set { align-self: center; }
.ready mat-chip { --mat-chip-label-text-color: #1b5e20; --mat-chip-outline-color: #66bb6a; }
.rejected mat-chip, .failed mat-chip { --mat-chip-label-text-color: #b71c1c; --mat-chip-outline-color: #e57373; }
.scanning mat-chip, .reading mat-chip { --mat-chip-label-text-color: #0d47a1; --mat-chip-outline-color: #64b5f6; }
Render <app-upload-queue /> from your root component. With the photo still being read, the list looks like this:
Reject infected files in the list
Rejection happens in two places, and both follow the verdict from our virus detection for uploads. In the browser, an infected: true verdict puts the row at Rejected, shows the detection name and keeps Submit disabled. On the server, the same verdict deletes the file with a remove policy for that one handle, because the scan leaves the file in storage. The removed set makes the delete run once per job, however many times the row polls.
Run the server and the dev server side by side:
node --env-file=.env server.mjs
npx ng serve
ng serve prints Local: http://localhost:4200/. Open it and choose the five files. The EICAR test file is a string antivirus engines agree to detect, so it exercises the Rejected path without real malware:
echo WDVPIVAlQEFQWzRcUFpYNTQoUF4pN0NDKTd9JEVJQ0FSLVNUQU5EQVJELUFOVElWSVJVUy1URVNULUZJTEUhJEgrSCo= | base64 -d > eicar.com
When eicar.com is rejected, the server prints its handle and the detection name:
API on http://localhost:3000
removed PKW08bukTlej5D7pbma7: content.malicious.eicar-test-signature
That handleâs CDN URL returns 404 from then on. Click Remove on the row and Submit unlocks:
The scan, the delete and Submit together are one form of secure file uploads: no file reaches the form until it has a clean verdict.
When a row stalls or fails
| Symptom | Cause | Fix |
|---|---|---|
| The list stays empty after choosing files | The FileList was read after the input was cleared |
Copy it with Array.from before the first await, as add does |
A row goes to Failed with policy error: the signature is not valid |
The policy wasnât signed with this applicationâs secret | Check FILESTACK_APP_SECRET against Policy & Signature in the console |
A row goes to Failed with policy error: the signature has expired |
The page has held the same policy past its one-hour expiry | Fetch a fresh /api/upload-config before each batch, as add does |
A row stays on Scanning and the server logs workflow_status 404 ... job not found |
The job ID belongs to another application, or the job is older than the workflowâs TTL of 172,800 seconds | Use the API key the upload used, and read results within the TTL |
| Rows reach Ready with no lines of text read | The OCR task is missing or named something other than ocr |
Match the task name in the console to the key the server reads |
A clean PDF shows not read (unsupported mimetype) |
OCR reads images | Convert the PDFâs pages to images first, or treat a clean PDF as ready |
ng build warns that the initial bundle exceeded its 500 kB budget |
filestack-js is part of the initial bundle | Raise the initial budget in angular.json, or lazy-load the route that holds the queue |
Angular Material file upload FAQ
Does Angular Material have a file upload component?
No. Angular Material has no built-in file upload component. You build one from mat-button, a hidden file input, mat-list and mat-progress-bar, and use a client like filestack-js to send the files.
Why does my Angular Material upload show 100% but the file isnât ready?
A progress bar measures bytes sent. When the last byte lands the upload resolves, but a workflow attached to that upload is still running. In this example the virus scan takes about ten seconds per file and OCR follows it, so the row needs states after 100% to show that work.
How do I show a scanning state in an Angular Material upload list?
Move the row to Scanning when the upload promise resolves, swap the determinate mat-progress-bar for an indeterminate one because the scan reports no percentage, and poll that fileâs workflow job. A job with no scan result yet keeps the row on Scanning.
How do I show a reading (OCR) state per file in Angular Material?
Poll the job and read its results. When virus_scan has returned but the job is still InProgress, the OCR task is running, so the server reports scanned: true and the row moves to Reading. When the job reaches Finished, the row becomes Ready.
Whatâs still yours
The queue, the server and the workflow run as they are. In your app, replace the line count with whatever the form needs from the OCR API output, such as the total on a receipt, and send the stored handles with the form when Submit is clicked. Polling suits a form that someone is watching. For files that finish after the user has left, the fs.workflow webhook delivers the same results to your server for every job. We include Virus Detection and OCR on the Start, Grow and Scale plans.
Joshua is a web developer with over 4 years of experience building responsive, high-performance websites and web applications. Currently working as an AI Automation Specialist, he combines modern web development with automation to create efficient, scalable digital solutions. He shares practical insights on WordPress, web development, and emerging technologies.
Read More â


