Angular File Upload — Drag-and-Drop, Progress Tracking and Preview

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.');
      },
    });
  }
}
Note: Client-side validation (file type and size check before uploading) provides fast feedback but is not a security control — it can be bypassed. Always validate on the server too. The client-side check exists purely for user experience: rejecting a 50MB file instantly is much better than uploading it for 30 seconds and then getting a server error. The server is the security boundary; the client is the UX layer.
Tip: The 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.
Warning: The 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()).

🧠 Test Yourself

A user selects a 4MB image file. The upload starts and the progress bar shows 80%. The network drops. What does the user see?