Feature 313 Feedback

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(); }
    });

    Are you sure?

    Rename

    ↑↓ moveEnter openEsc close