Upload progress in Vue
Show file upload progress in Vue forms with Qubeforms and XMLHttpRequest. Send multipart attachments, handle CORS and cancellation, and distinguish upload from receipt.
1. Add the external form stylesheet
Save the CSS below as a separate file in your website. HTML examples load it from /static/assets/css/form-helper.css; React and Vue import ./form-helper.css beside the component. Adjust the path to match your site and confirm the file loads before publishing. The generic .form-helper class moves the text input off-screen without the hidden attribute or display: none. Keep tabindex="-1", autocomplete="off", and aria-hidden="true". New forms use fax_number; existing forms keep their saved name. Match Settings → Delivery & spam → Honeypot field in HTML, JSON, and drafts. To rename an existing trap, update both the setting and the website together. Never use a name that also collects a real visitor answer.
.form-helper {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
overflow: hidden;
pointer-events: none;
}2. Understand what progress measures
Use this Vue 3 component on Starter, Pro, or Business when visitors need upload progress and cancellation. XMLHttpRequest.upload reports bytes sent by the browser. A value of 100 percent means the request body was sent, not that the server accepted the submission. The component waits for the final HTTP response and ok: true before clearing the form or reporting success. If the total is unknown it shows an indeterminate progress bar.
3. Create UploadForm.vue
Pass the same full endpoint prop used by the basic Vue form. The example uses required file selection, checks 1 byte to 5 MB per file and no more than 10 files, and leaves multipart boundaries to the browser. Its 120-second timeout is a client UX choice, not a Qubeforms retention or upload limit. Add your configured CAPTCHA response when required. The status is announced separately from the progress bar.
<script setup>
import { onBeforeUnmount, onMounted, ref } from 'vue';
import './form-helper.css';
const props = defineProps({ endpoint: { type: String, required: true } });
const sending = ref(false);
const progress = ref(null);
const feedback = ref('');
let currentRequest = null;
let startedAt = 0;
onMounted(() => { startedAt = Math.floor(Date.now() / 1000); });
onBeforeUnmount(() => { currentRequest?.abort(); });
function submit(event) {
if (sending.value) return;
const form = event.currentTarget;
const body = new FormData(form);
const files = [...body.values()].filter((value) => value instanceof File);
if (files.length > 10 || files.some((file) => file.size < 1 || file.size > 5 * 1024 * 1024)) {
feedback.value = 'Choose up to 10 files, each between 1 byte and 5 MB.';
return;
}
body.set('_cf_started_at', String(startedAt));
sending.value = true;
progress.value = null;
feedback.value = 'Uploading…';
const xhr = new XMLHttpRequest();
currentRequest = xhr;
xhr.upload.addEventListener('progress', (event) => {
progress.value = event.lengthComputable ? Math.round(event.loaded / event.total * 100) : null;
if (progress.value === 100) feedback.value = 'Upload sent. Waiting for confirmation…';
});
xhr.addEventListener('load', () => {
let result = null;
try { result = JSON.parse(xhr.responseText); } catch { /* Keep the fallback message. */ }
if (xhr.status >= 200 && xhr.status < 300 && result?.ok === true) {
feedback.value = 'Your submission was received.';
form.reset();
startedAt = Math.floor(Date.now() / 1000);
} else {
const retry = xhr.getResponseHeader('Retry-After');
feedback.value = (result?.error?.message || `Could not confirm receipt (HTTP ${xhr.status}).`)
+ (xhr.status === 429 && retry ? ` Retry after ${retry} seconds.` : '');
}
});
xhr.addEventListener('error', () => { feedback.value = 'Network error. Check receipt before retrying.'; });
xhr.addEventListener('abort', () => { feedback.value = 'Upload cancelled locally. Check whether it arrived.'; });
xhr.addEventListener('timeout', () => { feedback.value = 'Request timed out. Check receipt before retrying.'; });
xhr.addEventListener('loadend', () => { sending.value = false; currentRequest = null; });
try {
xhr.open('POST', props.endpoint);
xhr.setRequestHeader('Accept', 'application/json');
xhr.timeout = 120000;
xhr.send(body);
} catch {
sending.value = false;
currentRequest = null;
feedback.value = 'Could not start the upload. Check the endpoint and connection.';
}
}
function cancel() { currentRequest?.abort(); }
</script>
<template>
<form :action="endpoint" method="post" enctype="multipart/form-data" @submit.prevent="submit">
<fieldset :disabled="sending">
<legend>Send attachments</legend>
<label>Email <input name="email" type="email" required></label>
<label>Files <input name="attachments[]" type="file" multiple required accept=".pdf,.jpg,.jpeg,.png"></label>
<p>Up to 10 files. Maximum 5 MB each.</p>
<input class="form-helper" name="fax_number" type="text" tabindex="-1" autocomplete="off" aria-hidden="true">
<button type="submit">Upload</button>
</fieldset>
<progress v-if="sending" :value="progress === null ? undefined : progress" max="100" aria-label="Upload progress"></progress>
<button v-if="sending" type="button" @click="cancel">Cancel upload</button>
<p role="status" aria-live="polite">{{ feedback }}</p>
</form>
</template>4. Mount the component
Import UploadForm.vue into the parent and pass the public endpoint. In Nuxt, use the same runtimeConfig.public.qubeformsEndpoint value described in Nuxt client forms. No cloud-storage credentials are needed in the browser.
<script setup>
import UploadForm from './components/UploadForm.vue';
const endpoint = 'https://api.qubeforms.io/v1/cf_pk_YOUR_KEY/submissions';
</script>
<template>
<UploadForm :endpoint="endpoint" />
</template>5. Account for CORS and cancellation
Attaching upload event listeners can cause a cross-origin request to require an OPTIONS preflight. Qubeforms handles public-endpoint preflight, but the actual website origin still must be allowed. Cancellation and component unmount abort the local request; they do not remove a submission already received. The example leaves form values after cancellation, timeout, network failure, or a server error so the visitor can decide what to do next.
6. Keep attachments private and temporary
The file-picker accept attribute is a convenience, not a server guarantee. See Adding file uploads for blocked types, storage retention, and download links. Draft endpoints do not preserve file bytes, and browsers cannot repopulate a file selection after a page reload. Do not save files or draft tokens into shared URLs to simulate resuming.
7. Test with slow and failed requests
Use browser throttling to observe progress, then test the Cancel button and a failed response. Confirm that 100 percent does not briefly display a false success. Test an unknown-length progress event, a non-JSON response, an oversized file, and an invalid origin. The final response, not upload.load or loadend, determines whether receipt is confirmed.
Public form keys can appear in browser code. Read keys, management credentials, and webhook signing secrets must stay on the server.