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.
Table of contents
- 1. Why a plain file input is not enough
- 2. Base structure: file upload component with x-data
- 3. Image preview with FileReader without a server roundtrip
- 4. Drag and drop: dropping files instead of only clicking
- 5. Validating file size, type, and count
- 6. A real progress bar with XMLHttpRequest
- 7. Uploading multiple files in parallel with individual status
- 8. Error handling, cancellation, and retry
- 9. Upload approaches compared
- 10. Summary
- 11. FAQ
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.