File Upload with Preview and Progress Bar in Alpine.js
AI generated
x-data
Alpine
Alpine.js · File Upload · Drag and Drop · Forms
File Upload with Preview and Progress Bar
no external upload library, with real progress

A file upload that only shows a gray file picker button leaves users in the dark with large images or documents. With FileReader, drag and drop, and XMLHttpRequest you build a file upload with an instant image preview, clean validation, and a progress bar that shows real upload bytes instead of a simulated animation.

17 min read FileReader · XMLHttpRequest · drag and drop · validation Alpine.js 3.x

1. Why a plain file input is not enough

A native <input type="file"> only shows a cryptic file name after selection, with no preview and no feedback during the actual upload. For a file upload handling product images, profile photos, or documents, users today expect at least a visual confirmation of what they selected, and a visible progress indicator while the file is still transferring.

Alpine.js is well suited for a custom file upload, because the necessary building blocks, FileReader for the preview, drag events for the drag functionality, and XMLHttpRequest for real progress, are all native browser APIs. No additional upload library is needed to build a result that competes with commercial solutions.

The biggest mistake in self built upload components is simulating the progress bar via setInterval instead of measuring real bytes. A simulated bar that stalls at ninety percent while the server is still processing feels unfinished and undermines trust in the file upload. The following sections show how to display real progress instead.

2. Base structure: file upload component with x-data

The base structure of an Alpine component for file upload needs at least four states: the selected file, a preview URL, the progress value in percent, and a status string for loading, done, or error. This separation lets the template render each state independently, without nested conditions.

It is important not to store the file itself directly in reactive Alpine data, but only the derived values relevant for display. A File object can be stored in x-data, but should be treated like an immutable reference object that is only passed on to FormData and XMLHttpRequest.


// Base skeleton for a file upload component with preview and progress
function fileUpload() {
  return {
    file: null,
    previewUrl: null,
    progress: 0,
    status: 'idle', // idle | reading | uploading | success | error
    errorMessage: '',

    onFileSelected(event) {
      const selected = event.target.files[0];
      if (!selected) return;
      this.setFile(selected);
    },

    setFile(selected) {
      this.file = selected;
      this.status = 'idle';
      this.progress = 0;
      this.errorMessage = '';
    },

    reset() {
      this.file = null;
      this.previewUrl = null;
      this.progress = 0;
      this.status = 'idle';
      this.errorMessage = '';
    },
  };
}

3. Image preview with FileReader without a server roundtrip

The image preview of a file upload does not need to wait for the server. The FileReader API reads a selected file directly in the browser as a data URL and returns it in milliseconds, long before any network request has even started. The readAsDataURL() result can be bound directly as the src attribute of an img element.

For very large image files, an alternative is worth it: URL.createObjectURL(file) creates a blob URL, which is faster than reading the whole file as a base64 string, because no encoding takes place. The downside is that the blob URL must be explicitly released again with URL.revokeObjectURL() once the preview is no longer needed, otherwise the browser keeps the memory occupied unnecessarily long.


function fileUpload() {
  return {
    file: null,
    previewUrl: null,
    status: 'idle',

    setFile(selected) {
      this.file = selected;
      this.status = 'reading';

      // Revoke the previous object URL before creating a new one
      if (this.previewUrl) URL.revokeObjectURL(this.previewUrl);

      if (selected.type.startsWith('image/')) {
        this.previewUrl = URL.createObjectURL(selected);
      } else {
        this.previewUrl = null; // No preview for non-image files
      }
      this.status = 'idle';
    },

    destroy() {
      // Cleanup when the component is removed from the DOM
      if (this.previewUrl) URL.revokeObjectURL(this.previewUrl);
    },
  };
}

4. Drag and drop: dropping files instead of only clicking

Drag and drop significantly improves the usability of a file upload, especially on desktop devices. The implementation needs three events: dragover must call preventDefault() so the browser allows the drop at all, dragleave resets the visual hover state, and drop reads the files from event.dataTransfer.files, just like a regular file selection.

A common mistake is registering the dragover handler only on the outermost container, but nested child elements trigger their own dragenter/dragleave events on every transition, causing a flickering hover state. A counter that increments on dragenter and decrements on dragleave solves this problem reliably.


function fileUpload() {
  return {
    file: null,
    isDragging: false,
    dragCounter: 0,

    onDragEnter() {
      this.dragCounter++;
      this.isDragging = true;
    },

    onDragLeave() {
      this.dragCounter--;
      if (this.dragCounter === 0) this.isDragging = false;
    },

    onDrop(event) {
      this.dragCounter = 0;
      this.isDragging = false;
      const dropped = event.dataTransfer.files[0];
      if (dropped) this.setFile(dropped);
    },
  };
}

5. Validating file size, type, and count

Before a file upload is even started, it should check three things: the maximum file size, the allowed MIME type, and, for multi uploads, the maximum number of simultaneous files. This check belongs on the client to give users immediate feedback, but it does not replace server side validation, because file.type is easily manipulated in the browser and must never be treated as the sole security boundary.

For the size check, comparing file.size in bytes against a configured upper limit is enough, typically five to ten megabytes for images. For the type, an allowlist of permitted MIME types is more robust than a blocklist, because unexpected new file types would otherwise be let through by default.


function fileUpload() {
  return {
    file: null,
    errorMessage: '',
    maxSizeBytes: 8 * 1024 * 1024, // 8 MB
    allowedTypes: ['image/jpeg', 'image/png', 'image/webp'],

    setFile(selected) {
      const validationError = this.validate(selected);
      if (validationError) {
        this.errorMessage = validationError;
        this.file = null;
        return;
      }
      this.errorMessage = '';
      this.file = selected;
    },

    validate(candidate) {
      if (!this.allowedTypes.includes(candidate.type)) {
        return `File type ${candidate.type} is not supported`;
      }
      if (candidate.size > this.maxSizeBytes) {
        const maxMb = (this.maxSizeBytes / 1024 / 1024).toFixed(0);
        return `File is larger than ${maxMb} MB`;
      }
      return null;
    },
  };
}

6. A real progress bar with XMLHttpRequest

The modern fetch API offers no built in upload progress, because its streaming model provides no standard hook for it. For a file upload with a real progress bar, XMLHttpRequest therefore remains the right choice, specifically via the upload.onprogress event, which delivers loaded and total in bytes.

Progress is calculated as a percentage and bound directly to a reactive Alpine property that controls the width of a bar element via :style. Since onprogress fires very frequently, the native frequency is usually enough without needing an additional debounce, because Alpine's reactivity system batches DOM updates anyway.


function fileUpload() {
  return {
    file: null,
    progress: 0,
    status: 'idle',
    errorMessage: '',

    upload() {
      if (!this.file) return;
      this.status = 'uploading';
      this.progress = 0;

      const formData = new FormData();
      formData.append('file', this.file);

      const xhr = new XMLHttpRequest();
      xhr.open('POST', '/api/upload');

      // Real byte-level progress, not a simulated animation
      xhr.upload.addEventListener('progress', (event) => {
        if (event.lengthComputable) {
          this.progress = Math.round((event.loaded / event.total) * 100);
        }
      });

      xhr.addEventListener('load', () => {
        if (xhr.status >= 200 && xhr.status < 300) {
          this.status = 'success';
          this.progress = 100;
        } else {
          this.status = 'error';
          this.errorMessage = 'Upload failed';
        }
      });

      xhr.addEventListener('error', () => {
        this.status = 'error';
        this.errorMessage = 'Network error during upload';
      });

      xhr.send(formData);
    },
  };
}

7. Uploading multiple files in parallel with individual status

With multiple files, every file upload entry needs its own progress, status, and error state, independent of the other files. An array of upload objects, each with its own progress and status property, satisfies this requirement. The template then iterates with x-for over this array and shows a separate progress bar for each file.

Whether uploads start in parallel or sequentially depends on server capacity. For most use cases, limiting to three or four simultaneous uploads is enough, with a queue for the remaining files, similar to the bounded parallelism pattern for background processes.

8. Error handling, cancellation, and retry

A robust file upload must cover three error scenarios: the user manually cancels the upload, the connection drops, or the server rejects the file for business reasons. For manual cancellation, xhr.abort() is enough, bound to a cancel button that becomes visible during the upload.

After a failed upload, a retry button should appear that starts the same request again with the same file, without forcing the user to select the file a second time. This requires that the File object remains in the component even after an error and is not discarded prematurely.

9. Upload approaches compared

The table below compares typical decisions when building a custom file upload in Alpine.js.

Aspect Insufficient Recommended file upload Benefit
Progress setInterval simulation xhr.upload.onprogress real bytes, no stall at 90 percent
Preview requires a server roundtrip FileReader or object URL instant display in the browser
Validation checked server side only client check plus server check immediate feedback, secure boundary
Drag hover flickers with nested children counter for dragenter/dragleave stable visual state
Cancellation no way to stop the upload xhr.abort() on a cancel button control for the user

This pattern works regardless of whether the file upload is used for single profile pictures or for multi uploads in a document management system. The core building blocks, preview, validation, and real progress, stay identical.

Mironsoft

Alpine.js upload components and frontend development

File upload that feels like a foreign body?

We build you a file upload with preview, drag and drop, and a real progress bar that fits seamlessly into your existing Alpine.js frontend.

Upload component

Preview, drag and drop, and custom validation

Progress display

Real XMLHttpRequest progress instead of a simulated bar

Server integration

Server side validation for Magento and custom APIs

10. Summary

A well designed file upload in Alpine.js combines four building blocks: an instant image preview via FileReader or object URLs, drag and drop with a stable hover state via a counter, client side validation of size and type as a complement to server side checks, and a real progress bar via XMLHttpRequest instead of a simulated animation.

These building blocks need no external upload library, because all necessary capabilities are native browser APIs. With several simultaneous files, every file upload entry needs its own state, so a failed upload does not block the remaining files. Whoever consistently implements this structure gets an upload experience that competes with commercial solutions.

File Upload with Preview and Progress — The Essentials at a Glance

Preview

FileReader or URL.createObjectURL for instant image display without a server roundtrip.

Progress

xhr.upload.onprogress delivers real bytes, fetch offers no standard hook for this.

Validation

Check size and MIME type client side, secure again mandatorily server side.

Drag and drop

A counter for dragenter/dragleave prevents flickering hover state with child elements.

11. FAQ: File Upload with Preview and Progress in Alpine.js

1Why is a plain file input not enough?
It shows only a file name without preview or progress, insufficient for modern uploads.
2How to create a preview without a server?
FileReader.readAsDataURL() or URL.createObjectURL() read the file directly in the browser.
3Why no progress with fetch?
fetch offers no standard hook for upload progress, XMLHttpRequest remains the reliable choice here.
4Only validate client side?
No, file.type can be manipulated, server side validation remains mandatory.
5Avoid flickering hover state?
A counter for dragenter/dragleave only resets the hover state once it reaches zero.
6Multiple files at once?
An array of upload objects with its own status per file and bounded parallelism.
7Cancel an upload?
Bind xhr.abort() to a visible cancel button during the upload.
8After a failed upload?
A retry button starts the same request again with the same file.
9Release the blob URL?
Yes, with URL.revokeObjectURL(), once the preview is no longer needed.
10Need an external library?
No, FileReader, drag events, and XMLHttpRequest cover everything via native APIs.