Loading Spinner ตัวหมุนแสดงการโหลด
Spinners for buttons, inline text and the whole screen while a slow server call runs.
Live demo
Everything here talks to the real Spring Boot + SQL Server back end.Button spinner
Calls GET /api/demo/slow?ms=…; the server really waits that long.
Idle.
Spinner gallery
Loading
Loading
Loading
Loading
What to notice
- The button is disabled and keeps its width while it shows a spinner.
- aria-busy and a visually hidden 'Loading' label keep it accessible.
- Spinners pause under prefers-reduced-motion.
Try it
Source code
5 parts, read from the files in this project
src/main/java/com/study/uiux/web/DemoApi.java
A deliberately slow endpoint
@GetMapping("/demo/slow")
public Map<String, Object> slow(@RequestParam(defaultValue = "1500") int ms) throws InterruptedException {
long start = System.currentTimeMillis();
pause(ms);
return Map.of("waitedMs", System.currentTimeMillis() - start, "serverTime", Instant.now().toString());
}
src/main/resources/static/css/app.css
Spinners: a rotating border, three sizes, paused for reduced motion
.spinner {
--size: 1.15rem; width: var(--size); height: var(--size); border-radius: 50%; display: inline-block; flex: none;
border: calc(var(--size) / 7) solid color-mix(in srgb, currentColor 22%, transparent); border-top-color: currentColor;
animation: spin .7s linear infinite; vertical-align: -.2em;
}
.spinner-sm { --size: .85rem; }
.spinner-lg { --size: 2.6rem; color: var(--primary); }
.spinner-xl { --size: 3.5rem; color: var(--primary); }
@keyframes spin { to { transform: rotate(360deg); } }
.dots { display: inline-flex; gap: .3rem; }
.dots span { width: .5rem; height: .5rem; border-radius: 50%; background: currentColor; animation: bounce 1s infinite ease-in-out; }
.dots span:nth-child(2) { animation-delay: .15s; }
.dots span:nth-child(3) { animation-delay: .3s; }
@keyframes bounce { 0%, 80%, 100% { transform: scale(.5); opacity: .4; } 40% { transform: scale(1); opacity: 1; } }
.btn.is-loading { color: transparent !important; pointer-events: none; }
.btn.is-loading::after {
content: ""; position: absolute; inset: 0; margin: auto; width: 1.1rem; height: 1.1rem; border-radius: 50%;
border: 2px solid color-mix(in srgb, var(--btn-fg) 30%, transparent); border-top-color: var(--btn-fg); animation: spin .7s linear infinite;
}
.loading-overlay { position: fixed; inset: 0; z-index: 150; display: grid; place-items: center; background: color-mix(in srgb, var(--bg) 70%, transparent); backdrop-filter: blur(3px); animation: fade-in .2s; }
.loading-box { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); box-shadow: var(--shadow-lg); padding: 1.75rem 2.25rem; text-align: center; display: flex; flex-direction: column; align-items: center; gap: .9rem; }
.loading-box p { margin: 0; font-weight: 600; }
src/main/resources/static/css/app.css
Respect "reduce motion": no animation, no smooth scrolling
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: .001ms !important; animation-iteration-count: 1 !important; transition-duration: .001ms !important; scroll-behavior: auto !important; }
.spinner, .btn.is-loading::after { animation: spin 1.5s linear infinite !important; animation-duration: 1.5s !important; animation-iteration-count: infinite !important; }
}
src/main/resources/static/js/core.js
Full-screen loading overlay that can be cancelled
const loading = (() => {
const overlay = $('#loadingOverlay');
let onCancel = null;
$('#loadingCancel').addEventListener('click', () => onCancel?.());
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !overlay.hidden) onCancel?.(); });
return {
show(text = 'Loading…', cancel = null) {
$('#loadingText').textContent = text;
onCancel = cancel;
$('#loadingCancel').hidden = !cancel;
overlay.hidden = false;
overlay.setAttribute('aria-busy', 'true');
$('#loadingCancel').focus();
},
hide() { overlay.hidden = true; overlay.removeAttribute('aria-busy'); onCancel = null; }
};
})();
/** Put a button into the loading state while a promise runs. */
async function withButton(btn, work) {
btn.classList.add('is-loading');
btn.setAttribute('aria-busy', 'true');
btn.disabled = true;
try { return await work(); } finally {
btn.classList.remove('is-loading'); btn.removeAttribute('aria-busy'); btn.disabled = false;
}
}
src/main/resources/static/js/demos.js
Button spinner + AbortController for the cancellable overlay
$('#slowBtn').addEventListener('click', async (e) => {
const ms = $('#slowDelay').value, t0 = performance.now();
try {
const r = await UI.withButton(e.currentTarget, () => api('/api/demo/slow?ms=' + ms));
write(`<b>button</b> server waited ${r.waitedMs} ms, round trip ${Math.round(performance.now() - t0)} ms`);
} catch (err) { toast({ type: 'error', title: 'Failed', message: err.message }); }
});
$('#overlayBtn').addEventListener('click', async () => {
const ctrl = new AbortController();
UI.loading.show('Waiting for the server…', () => ctrl.abort());
try {
const r = await api('/api/demo/slow?ms=' + Math.max(3000, $('#slowDelay').value), { signal: ctrl.signal });
write(`<b>overlay</b> finished after ${r.waitedMs} ms`);
} catch (err) {
if (err.name === 'AbortError') { write('<b>overlay</b> cancelled by the user'); toast({ type: 'warning', title: 'Cancelled', message: 'The request was aborted.' }); }
else toast({ type: 'error', title: 'Failed', message: err.message });
} finally { UI.loading.hide(); $('#overlayBtn').focus(); }
});