← Back to UI screens

Live: try it with the mouse and the keyboard (Tab, arrows, Escape).

ScreenshotLoading spinner component — web, minimal style
Theme

Loading spinner component — web, minimal style

Loading spinner UI component for websites and web apps, shown in Lumen Desk, a fictional ai product. Shows loading spinners in three sizes, a button that shows a busy state, an inline loading status with screen-reader text, and every state (default, hover, focus-visible, busy, disabled). Minimal style: cool neutrals, Inter, 8px corners and soft shadows. Comes with copyable HTML, CSS and a few lines of plain JavaScript (keyboard support and ARIA per the WAI-ARIA Authoring Practices), a live demo, and a DESIGN.md with light and dark colour tokens.

Open live HTML ↗ Download DESIGN.md
Pattern
Loading spinner
Device
Web
Style
Minimal
Product (fictional)
Lumen Desk
Category
AI

Spec

Colours (light)
background #f6f9fe · surface #e8eff9 · text #1b232c · muted #5a6471 · primary #046acd · accent #7e73d4
Colours (dark)
background #0b1016 · surface #151c24 · text #f0f3f7 · muted #a9b2be · primary #74b1ff · accent #a79fff
Fonts
Inter (headings and body), JetBrains Mono (code) · Google Fonts, under their own licences
Light and dark
Both included; follows the system setting
ARIA pattern
Semantic HTML, no widget role needed
Keyboard
No keys of its own; the busy button is announced
States
default, hover, focus-visible, busy, disabled
spinnerwebaibuttoncard

Code

Paste the HTML where the component goes, the CSS into your stylesheet and the script before </body>. The tokens block holds the light and dark colours; swap in your own.

HTML
<div class="mgd-load">
  <div class="mgd-load__sizes" aria-hidden="true"><span class="mgd-spin mgd-spin--sm"></span><span class="mgd-spin"></span><span class="mgd-spin mgd-spin--lg"></span></div>
  <div class="mgd-load__card" role="status"><span class="mgd-spin"></span><span><b>Reading 24 sources…</b><span>Lumen Desk is pulling quotes and figures from your folder.</span></span></div>
  <div class="mgd-load__row">
    <button class="mgd-btn mgd-btn--primary" type="button" data-busy-label="Summarising…"><svg class="i" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3l1.8 5.4L19 10l-5.2 1.6L12 17l-1.8-5.4L5 10l5.2-1.6z"/></svg><span>Summarise sources</span></button>
    <button class="mgd-btn" type="button" disabled>Export brief</button>
    <span class="mgd-load__msg" role="status"></span>
  </div>
</div>
CSS
/* Tokens (light + dark) */
:root{--bg:#f6f9fe;--surface:#e8eff9;--border:#c5cfdb;--text:#1b232c;--muted:#5a6471;--primary:#046acd;--on-primary:#ffffff;--accent:#7e73d4;--font-h:"Inter",Georgia,serif;--font-b:"Inter",system-ui,sans-serif;--hw:600;
--c-bw:1px;--c-line:var(--border);--c-r:8px;--c-pill:8px;--c-sh:0 1px 2px rgb(0 0 0 / .06);--c-pop:0 12px 32px rgb(0 0 0 / .14);--c-tt:none;--c-ls:0;--c-fw:500;--c-fs:14px;--c-ring:2px solid var(--primary);--c-ind:2px;color-scheme:light}
@media (prefers-color-scheme:dark){:root:not([data-theme="light"]){--bg:#0b1016;--surface:#151c24;--border:#2b323b;--text:#f0f3f7;--muted:#a9b2be;--primary:#74b1ff;--on-primary:#111111;--accent:#a79fff;color-scheme:dark}
}
:root[data-theme="dark"]{--bg:#0b1016;--surface:#151c24;--border:#2b323b;--text:#f0f3f7;--muted:#a9b2be;--primary:#74b1ff;--on-primary:#111111;--accent:#a79fff;color-scheme:dark}

/* Component */
.mgd-sr{position:absolute!important;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0}
.mgd-btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:40px;padding:0 16px;border:var(--c-bw) solid var(--c-line);border-radius:var(--c-pill);background:var(--surface);color:var(--text);font:var(--c-fw) var(--c-fs)/1 var(--font-b);text-transform:var(--c-tt);letter-spacing:var(--c-ls);box-shadow:var(--c-sh);cursor:pointer;white-space:nowrap;transition:background-color .12s,border-color .12s}
.mgd-btn:hover,.mgd-btn.is-hover{background:color-mix(in oklab,var(--primary) 9%,var(--surface))}
.mgd-btn:focus-visible,.mgd-btn.is-focus{outline:var(--c-ring);outline-offset:2px}
.mgd-btn[aria-expanded="true"],.mgd-btn.is-open{background:color-mix(in oklab,var(--primary) 15%,var(--surface))}
.mgd-btn--primary{background:var(--primary);border-color:var(--primary);color:var(--on-primary)}
.mgd-btn--primary:hover,.mgd-btn--primary.is-hover{background:color-mix(in oklab,var(--primary) 84%,var(--text))}
.mgd-btn:disabled,.mgd-btn[aria-disabled="true"],.mgd-btn.is-disabled{opacity:.5;cursor:not-allowed;box-shadow:none}
.mgd-btn svg{flex:none}
.mgd-spin{display:inline-block;flex:none;width:var(--size,24px);height:var(--size,24px);border:3px solid color-mix(in oklab,var(--muted) 30%,transparent);border-top-color:var(--primary);border-radius:50%;animation:mgd-spin .8s linear infinite;vertical-align:middle}
.mgd-spin--sm{--size:16px;border-width:2px}
.mgd-spin--lg{--size:44px;border-width:4px}
@keyframes mgd-spin{to{transform:rotate(360deg)}}
@keyframes mgd-pulse{50%{opacity:.35}}
@media (prefers-reduced-motion:reduce){.mgd-spin{animation:mgd-spin 2.4s linear infinite,mgd-pulse 1.6s ease-in-out infinite}}
.mgd-btn[aria-busy="true"]{cursor:progress}
.mgd-load{display:grid;gap:22px;max-width:640px}
.mgd-load__sizes{display:flex;align-items:center;gap:26px}
.mgd-load__card{display:flex;align-items:center;gap:14px;padding:16px 18px;background:var(--surface);border:var(--c-bw) solid var(--c-line);border-radius:var(--c-r);box-shadow:var(--c-sh)}
.mgd-load__card b{display:block;font-weight:600}
.mgd-load__card span{color:var(--muted);font-size:13px}
.mgd-load__row{display:flex;flex-wrap:wrap;align-items:center;gap:12px}
.mgd-load__msg{color:var(--muted);font-size:14px}
JavaScript
document.querySelectorAll("[data-busy-label]").forEach(function (btn) {
  var label = btn.querySelector("span"), idle = label.textContent, icon = btn.querySelector("svg"), msg = btn.parentNode.querySelector("[role=status]");
  btn.addEventListener("click", function () {
    if (btn.getAttribute("aria-busy") === "true") return;
    btn.setAttribute("aria-busy", "true"); btn.setAttribute("aria-disabled", "true");
    var spin = document.createElement("span"); spin.className = "mgd-spin mgd-spin--sm"; spin.setAttribute("aria-hidden", "true");
    if (icon) icon.replaceWith(spin); else btn.prepend(spin);
    label.textContent = btn.dataset.busyLabel; if (msg) msg.textContent = "Summarising your sources…";
    setTimeout(function () {
      btn.removeAttribute("aria-busy"); btn.removeAttribute("aria-disabled");
      spin.replaceWith(icon || document.createTextNode("")); label.textContent = idle; if (msg) msg.textContent = "Summary ready.";
    }, 2500);
  });
});

DESIGN.md

Component section (states, ARIA, keyboard)
## Component: Loading spinner

Indeterminate loading indicators in three sizes, a busy button and an inline status, with reduced-motion support.

- **Pattern:** Semantic HTML (no ARIA widget role needed)
- **Roles and attributes:** A spinner sits inside role="status" with visually hidden text ("Loading…"), so the message is announced once; a busy button is disabled with aria-busy="true" while it works. With prefers-reduced-motion the spin slows to a gentle pulse.
- **States drawn:** default, hover, focus-visible, busy, disabled
- **Focus:** a visible `:focus-visible` outline (2px solid primary), never removed
- **Style knobs (minimal):** border 1px, corners 8px, shadow `0 1px 2px rgb(0 0 0 / .06)`, labels sentence case
- **Motion:** transitions are short (≤160ms) and switched off under `prefers-reduced-motion: reduce`
- **Code:** HTML + CSS + a few lines of plain JavaScript (no libraries); colours come only from the tokens above, so light and dark both work

### Keyboard
- **Tab:** reach the Save button
- **Enter / Space:** start the busy state

### Licence
Original Made Good Designs code: free to use and adapt in any project, including commercial ones, with no credit needed. Fonts are Google Fonts under their own licences.
AI

Build this with your AI

Fonts: Inter (Google Fonts)

Writing about this component?

More loading spinner components

Loading spinner component — web, editorial (Lumen Desk)
web · editorial
Loading spinner component — web, brutalist (Lumen Desk)
web · brutalist
Loading spinner component — web, luxury (Lumen Desk)
web · luxury
Loading spinner component — web, playful (Lumen Desk)
web · playful
Loading spinner component — web, corporate (Lumen Desk)
web · corporate
Loading spinner component — web, retro (Lumen Desk)
web · retro
Loading spinner component — web, monochrome (Lumen Desk)
web · monochrome
Loading spinner component — mobile, minimal (Lumen Desk)
mobile · minimal
Building with an AI coding agent? Every screen here is also in the free Made Good Designs MCP server: ask Claude, Cursor or ChatGPT to search_ui_screens (e.g. “billing settings, editorial style”), get_ui_flow for a whole journey, and get_ui_screen_code to pull the HTML and DESIGN.md straight into your project.

Save to board