A polished file upload component provides immediate visual feedback at every step: a drag-and-drop zone that highlights on hover, an instant preview of the selected image, a progress bar during upload, and clear success/error states. Building it as a reusable ImageUploadComponent with an @Output() uploaded event means it can be dropped into both the post form (cover image) and the profile page (avatar) without code duplication.
Angular Image Upload Component
@Component({
selector: 'app-image-upload',
standalone: true,
imports: [MatProgressBarModule, MatIconModule, MatButtonModule],
template: `
<!-- Drag-and-drop zone ─────────────────────────────────────────────── -->
<div class="upload-zone"
[class.drag-over]="isDragOver()"
[class.has-image]="previewUrl()"
(dragover)="onDragOver($event)"
(dragleave)="isDragOver.set(false)"
(drop)="onDrop($event)"
(click)="fileInput.click()">
@if (previewUrl()) {
<!-- Image preview ─────────────────────────────────────────────── -->
<img [src]="previewUrl()" alt="Preview" class="preview">
<div class="overlay">
<mat-icon>edit</mat-icon>
<span>Change image</span>
</div>
} @else {
<!-- Placeholder ───────────────────────────────────────────────── -->
<mat-icon class="upload-icon">cloud_upload</mat-icon>
<p>Drop an image or <strong>click to browse</strong></p>
<p class="hint">JPEG, PNG, WebP · Max 5MB</p>
}
</div>
<!-- Hidden file input ──────────────────────────────────────────────── -->
<input #fileInput type="file"
accept="image/jpeg,image/png,image/webp"
style="display:none"
(change)="onFileSelected($event)">
<!-- Upload progress ────────────────────────────────────────────────── -->
@if (uploading()) {
<mat-progress-bar mode="determinate" [value]="uploadProgress()" />
<p>Uploading... {{ uploadProgress() }}%</p>
}
@if (error()) {
<mat-error>{{ error() }}</mat-error>
}
`,
styles: [`
.upload-zone {
border: 2px dashed var(--mat-sys-outline);
border-radius: 8px; padding: 2rem; cursor: pointer;
text-align: center; transition: all 200ms;
position: relative; overflow: hidden;
}
.upload-zone.drag-over { border-color: var(--mat-sys-primary); background: var(--mat-sys-primary-container); }
.upload-zone.has-image { padding: 0; }
.preview { width: 100%; height: 200px; object-fit: cover; display: block; }
.overlay { position: absolute; inset: 0; background: rgba(0,0,0,0.5);
display: flex; flex-direction: column; align-items: center;
justify-content: center; color: white; opacity: 0;
transition: opacity 200ms; }
.has-image:hover .overlay { opacity: 1; }
`],
})
export class ImageUploadComponent {
@Input() maxSizeMb = 5;
@Input() aspectRatio?: string; // e.g. '16:9' for cover, '1:1' for avatar
@Output() uploaded = new EventEmitter<string>(); // emits the final URL
private http = inject(HttpClient);
private config = inject(APP_CONFIG);
previewUrl = signal<string | null>(null);
uploading = signal(false);
uploadProgress = signal(0);
error = signal<string | null>(null);
isDragOver = signal(false);
// ── Drag and drop ─────────────────────────────────────────────────────
onDragOver(event: DragEvent): void {
event.preventDefault(); event.stopPropagation();
this.isDragOver.set(true);
}
onDrop(event: DragEvent): void {
event.preventDefault(); event.stopPropagation();
this.isDragOver.set(false);
const file = event.dataTransfer?.files[0];
if (file) this.processFile(file);
}
onFileSelected(event: Event): void {
const file = (event.target as HTMLInputElement).files?.[0];
if (file) this.processFile(file);
}
private processFile(file: File): void {
this.error.set(null);
// Client-side validation
if (!file.type.startsWith('image/')) {
this.error.set('Only image files are allowed.'); return;
}
if (file.size > this.maxSizeMb * 1024 * 1024) {
this.error.set(`Image must be under ${this.maxSizeMb}MB.`); return;
}
// Show preview immediately
const reader = new FileReader();
reader.onload = e => this.previewUrl.set(e.target!.result as string);
reader.readAsDataURL(file);
// Upload with progress tracking
const formData = new FormData();
formData.append('file', file);
this.uploading.set(true);
this.http.post<{ url: string }>(`${this.config.apiUrl}/api/uploads/images`,
formData,
{
reportProgress: true,
observe: 'events',
withCredentials: true,
}
).subscribe({
next: event => {
if (event.type === HttpEventType.UploadProgress) {
this.uploadProgress.set(
Math.round(100 * (event.loaded / (event.total ?? event.loaded)))
);
} else if (event.type === HttpEventType.Response) {
this.uploading.set(false);
this.uploaded.emit(event.body!.url);
}
},
error: () => {
this.uploading.set(false);
this.previewUrl.set(null);
this.error.set('Upload failed. Please try again.');
},
});
}
}
FileReader.readAsDataURL() creates a base64 data URL that can be used directly as an src attribute for preview — no server round-trip needed. The preview appears instantly after file selection while the upload happens in the background. Use URL.createObjectURL(file) as an alternative — it creates a blob URL that is more memory-efficient for large files and is revoked with URL.revokeObjectURL() when no longer needed. Both approaches show the preview before the upload completes.observe: 'events' option changes the Observable type from Observable<ResponseType> to Observable<HttpEvent<ResponseType>>. Without it, Angular only emits the final response. With it, all HTTP events are emitted including upload progress, download progress, and the response. The HttpEventType enum differentiates event types. Only use observe: 'events' when you need progress tracking — for regular API calls, the default observe: 'body' is simpler.Common Mistakes
Mistake 1 — Using JSON to send files (FormData required for multipart)
❌ Wrong — http.post('/api/uploads', { fileData: base64String }); large payload, no streaming, base64 inflates size by 33%.
✅ Correct — FormData with formData.append('file', file); multipart encoding, supports streaming, no size inflation.
Mistake 2 — Not revoking Object URLs (memory leak)
❌ Wrong — URL.createObjectURL(file) without URL.revokeObjectURL(); blob stays in memory after component destruction.
✅ Correct — revoke on component destroy or when a new file replaces the old preview: URL.revokeObjectURL(this.previewUrl()).