Progress Bar แถบความคืบหน้า
Determinate and indeterminate bars: a real upload with XHR progress events, a server job polled from SQL Server and a top page bar.
Live demo
Everything here talks to the real Spring Boot + SQL Server back end.Upload with real progress
The file goes to POST /api/demo/upload. The server reads it and throws it away (nothing is stored).
Server job (progress stored in job_run)
A background thread reads every product and writes done_steps. The page asks for the row every 400 ms.
idle
| # | Job | Status | Steps | Result |
|---|---|---|---|---|
| No jobs yet. | ||||
Other styles
Indeterminate (unknown length)
Success / danger
Native <progress>
What to notice
- XMLHttpRequest.upload.onprogress gives real byte counts (fetch() cannot).
- The background job writes done_steps to job_run; the page polls it every 400 ms.
- <progress> and role=progressbar with aria-valuenow announce the value.
Try it
Source code
9 parts, read from the files in this project
src/main/java/com/study/uiux/web/DemoApi.java
The upload endpoint reads the bytes and only reports the size
@PostMapping("/demo/upload")
public Map<String, Object> upload(@RequestParam("file") MultipartFile file) throws IOException {
long bytes = 0;
try (InputStream in = file.getInputStream()) {
byte[] buffer = new byte[64 * 1024];
for (int n; (n = in.read(buffer)) > 0; ) bytes += n;
}
return Map.of("name", String.valueOf(file.getOriginalFilename()), "bytes", bytes);
}
src/main/java/com/study/uiux/web/DemoApi.java
GET /api/jobs/{id}: the page polls this
@GetMapping("/jobs/{id}")
public Map<String, Object> jobStatus(@PathVariable int id) {
return job(jobs.get(id));
}
src/main/java/com/study/uiux/service/JobService.java
Start a background job and return its id
public JobRun start() {
boolean busy = jobs.findTop5ByOrderByIdDesc().stream().anyMatch(j -> "RUNNING".equals(j.getStatus())
&& j.getStartedAt().isAfter(Instant.now().minusSeconds(120)));
if (busy) throw ApiException.badRequest("A job is already running. Wait until it finishes.");
JobRun job = jobs.save(new JobRun("Recalculate stock value", (int) products.count()));
worker.run(job.getId()); // @Async: returns immediately
return job;
}
src/main/java/com/study/uiux/service/JobService.java
The worker writes progress to job_run after every product
@Async
public void run(int jobId) {
JobRun job = jobs.findById(jobId).orElseThrow();
try {
BigDecimal total = BigDecimal.ZERO;
List<Product> all = products.findAll();
for (int i = 0; i < all.size(); i++) {
Product p = all.get(i);
total = total.add(p.getPrice().multiply(BigDecimal.valueOf(p.getStock())));
Thread.sleep(45); // pretend each product takes a while
if (i % 4 == 3 || i == all.size() - 1) {
job.setDoneSteps(i + 1);
job.setMessage("Checked " + p.getName());
job = jobs.save(job); // UPDATE job_run SET done_steps = ?
}
}
job.setStatus("DONE");
job.setMessage(String.format("Stock value: $%,.2f", total));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
job.setStatus("FAILED");
job.setMessage("Stopped");
} catch (RuntimeException e) {
job.setStatus("FAILED");
job.setMessage(e.getMessage() == null ? "Failed" : e.getMessage());
}
job.setFinishedAt(Instant.now());
jobs.save(job);
}
src/main/resources/db/migration/V1__schema.sql
job_run: a background job writes its progress here, the page polls it
CREATE TABLE job_run (
id INT IDENTITY(1,1) NOT NULL CONSTRAINT pk_job_run PRIMARY KEY,
name NVARCHAR(80) NOT NULL,
total_steps INT NOT NULL,
done_steps INT NOT NULL CONSTRAINT df_job_done DEFAULT 0,
status NVARCHAR(10) NOT NULL CONSTRAINT df_job_status DEFAULT N'RUNNING',
message NVARCHAR(200) NOT NULL CONSTRAINT df_job_message DEFAULT N'',
started_at DATETIME2(0) NOT NULL CONSTRAINT df_job_started DEFAULT SYSUTCDATETIME(),
finished_at DATETIME2(0) NULL,
CONSTRAINT ck_job_status CHECK (status IN (N'RUNNING', N'DONE', N'FAILED'))
);
src/main/resources/static/css/app.css
Progress bars: determinate, striped and indeterminate
.progress { height: .65rem; background: var(--surface-3); border-radius: 999px; overflow: hidden; position: relative; }
.progress-bar { height: 100%; width: 0; background: var(--primary); border-radius: inherit; transition: width .25s var(--ease); }
.progress.striped .progress-bar {
background-image: linear-gradient(45deg, rgb(255 255 255 / .22) 25%, transparent 25%, transparent 50%, rgb(255 255 255 / .22) 50%, rgb(255 255 255 / .22) 75%, transparent 75%);
background-size: 1rem 1rem; animation: stripes .8s linear infinite;
}
@keyframes stripes { to { background-position: 1rem 0; } }
.progress.indeterminate .progress-bar { width: 35%; position: absolute; animation: indeterminate 1.3s infinite var(--ease); }
@keyframes indeterminate { from { left: -35%; } to { left: 100%; } }
.progress.success .progress-bar { background: var(--success); }
.progress.danger .progress-bar { background: var(--danger); }
.top-progress { position: fixed; top: 0; left: 0; right: 0; height: 3px; z-index: 300; pointer-events: none; }
.top-progress span { display: block; height: 100%; width: 0; background: var(--primary); box-shadow: 0 0 10px var(--primary); opacity: 0; transition: width .3s ease, opacity .3s; }
.top-progress.active span { opacity: 1; }
src/main/resources/static/js/core.js
The thin bar at the top of the window follows every API call
const topProgress = (() => {
const bar = $('#topProgress');
const fill = bar && bar.firstElementChild;
let active = 0, showTimer = null, trickle = null, width = 0;
function set(w) { width = w; if (fill) fill.style.width = w + '%'; }
return {
start() {
if (!bar) return;
active++;
if (active > 1) return;
clearTimeout(showTimer);
showTimer = setTimeout(() => {
bar.classList.add('active'); set(8);
trickle = setInterval(() => set(width + (90 - width) * 0.08), 200); // slows down near 90%
}, 150);
},
done() {
if (!bar || active === 0) return;
active--;
if (active > 0) return;
clearTimeout(showTimer); clearInterval(trickle);
if (!bar.classList.contains('active')) return;
set(100);
setTimeout(() => { bar.classList.remove('active'); setTimeout(() => set(0), 300); }, 250);
}
};
})();
src/main/resources/static/js/demos.js
XMLHttpRequest gives upload progress events (fetch does not)
function upload(file) {
if (file.size > 50 * 1024 * 1024) { toast({ type: 'error', title: 'Too large', message: 'The limit is 50 MB.' }); return; }
const bar = $('#upBar'), xhr = new XMLHttpRequest(), form = new FormData(), t0 = performance.now();
form.append('file', file);
bar.classList.remove('success', 'danger'); bar.classList.add('striped');
xhr.upload.onprogress = (e) => {
if (!e.lengthComputable) return;
const pct = (e.loaded / e.total) * 100, secs = (performance.now() - t0) / 1000;
setBar(bar, pct);
$('#upText').textContent = `${fmt(e.loaded)} of ${fmt(e.total)} (${pct.toFixed(0)}%)`;
$('#upSpeed').textContent = secs > 0.2 ? fmt(e.loaded / secs) + '/s' : '';
};
xhr.onload = () => {
bar.classList.remove('striped');
if (xhr.status === 200) {
const r = JSON.parse(xhr.responseText);
bar.classList.add('success'); setBar(bar, 100);
$('#upText').textContent = `Server received ${fmt(r.bytes)} of "${r.name}"`;
toast({ type: 'success', title: 'Upload finished', message: r.name });
} else { bar.classList.add('danger'); $('#upText').textContent = 'Upload failed (' + xhr.status + ')'; }
};
xhr.onerror = () => { bar.classList.add('danger'); $('#upText').textContent = 'Network error'; };
xhr.open('POST', '/api/demo/upload');
xhr.send(form);
}
src/main/resources/static/js/demos.js
Poll the job row until it is DONE
async function follow(id) {
const bar = $('#jobBar'), btn = $('#jobBtn');
btn.disabled = true;
for (;;) {
const j = await api('/api/jobs/' + id, { silent: true });
setBar(bar, j.percent);
$('#jobPct').textContent = j.percent + '%';
$('#jobText').textContent = `${j.done} / ${j.total} · ${j.message}`;
$('#jobState').textContent = j.status.toLowerCase();
if (j.status !== 'RUNNING') {
btn.disabled = false;
toast({ type: j.status === 'DONE' ? 'success' : 'error', title: 'Job ' + j.status.toLowerCase(), message: j.message });
refreshJobs();
return;
}
await new Promise((r) => setTimeout(r, 400));
}
}