Component Library

← Back to gallery

Loading button

Category: Buttons · Tags: loading, spinner, aria-busy, async, save

How to use it

Use for an action that takes a noticeable moment, such as saving a form to a server, so people see it is working and do not click twice. For instant actions skip the spinner, and for long jobs with measurable progress show a progress bar instead. Replace the 1.5 second demo timer with your real request, and set the status text from its result -- including a failure message.

Accessibility

A native <button> that, while the save runs, gets aria-busy="true" and aria-disabled="true" and ignores further clicks; aria-disabled is used instead of the disabled attribute so keyboard focus stays on the button rather than being dropped. Its text changes from "Save changes" to "Saving…" next to a decorative aria-hidden spinner (which stops spinning under prefers-reduced-motion), and a separate role="status" line announces "Changes saved." when the work finishes. The focus outline is 2px. Text is #ffffff on #1d4ed8 (6.70:1), on the #1e40af hover (8.72:1) and on the #475569 busy state (7.58:1); the status line is #166534 on #ffffff (7.13:1).

Source (copy-ready)

<div class="dcl-loadbtn">
  <button type="button" class="dcl-loadbtn__button">
    <span class="dcl-loadbtn__spinner" aria-hidden="true"></span>
    <span class="dcl-loadbtn__label">Save changes</span>
  </button>
  <p class="dcl-loadbtn__status" role="status"></p>
</div>
<style>
.dcl-loadbtn { font: inherit; color: #0f172a; background-color: #ffffff; }
.dcl-loadbtn__button { display: inline-flex; align-items: center; gap: 0.5rem; min-height: 2.75rem; padding: 0 1.25rem; border: none; border-radius: 0.5rem; cursor: pointer; font: inherit; font-weight: 600; color: #ffffff; background-color: #1d4ed8; }
.dcl-loadbtn__button:hover { color: #ffffff; background-color: #1e40af; }
.dcl-loadbtn__button:focus-visible { outline: 2px solid #0f172a; outline-offset: 2px; }
.dcl-loadbtn__button[aria-busy="true"] { cursor: progress; color: #ffffff; background-color: #475569; }
.dcl-loadbtn__label { color: #ffffff; }
.dcl-loadbtn__spinner { display: none; width: 1rem; height: 1rem; border: 2px solid #ffffff; border-right-color: #94a3b8; border-radius: 50%; animation: dcl-loadbtn-spin 0.8s linear infinite; }
.dcl-loadbtn__button[aria-busy="true"] .dcl-loadbtn__spinner { display: inline-block; }
@keyframes dcl-loadbtn-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .dcl-loadbtn__spinner { animation: none; } }
.dcl-loadbtn__status { min-height: 1.5em; margin: 0.5rem 0 0; color: #166534; background-color: #ffffff; }
</style>
<script>
(function () {
  document.querySelectorAll(".dcl-loadbtn").forEach(function (root) {
    if (root.dataset.dclBound) { return; }
    root.dataset.dclBound = "true";
    var button = root.querySelector(".dcl-loadbtn__button");
    var label = root.querySelector(".dcl-loadbtn__label");
    var status = root.querySelector(".dcl-loadbtn__status");
    if (!button) { return; }
    var idle = label ? label.textContent : "";
    button.addEventListener("click", function () {
      if (button.getAttribute("aria-busy") === "true") { return; }
      button.setAttribute("aria-busy", "true");
      button.setAttribute("aria-disabled", "true");
      if (label) { label.textContent = "Saving…"; }
      if (status) { status.textContent = ""; }
      window.setTimeout(function () {
        button.removeAttribute("aria-busy");
        button.removeAttribute("aria-disabled");
        if (label) { label.textContent = idle; }
        if (status) { status.textContent = "Changes saved."; }
      }, 1500);
    });
  });
})();
</script>

Was this useful? Send feedback.