Open demo

CSS code

HTML
<button type="button" class="lb" data-loading-btn><span class="lb-spin" aria-hidden="true"></span><span class="lb-ok" aria-hidden="true">✓</span><span class="lb-label" data-label>Save changes</span></button>
<button type="button" class="lb lb-bar" data-loading-btn><span class="lb-spin" aria-hidden="true"></span><span class="lb-ok" aria-hidden="true">✓</span><span class="lb-label" data-label>Upload file</span></button>
<p class="lb-sr" role="status" data-loading-status></p>
CSS
/* Loading buttons: the script sets aria-busy="true" while working and data-state="done" on success; CSS shows the spinner or the tick. */
.lb{position:relative;overflow:hidden;display:inline-flex;align-items:center;justify-content:center;min-width:190px;border:0;border-radius:12px;
  padding:14px 32px;background:#2563eb;color:#fff;font:inherit;font-size:.92rem;font-weight:700;line-height:1.25;cursor:pointer;
  transition:background-color .2s,filter .15s}
.lb:hover{filter:brightness(1.08)}
.lb:focus-visible{outline:2px solid #2563eb;outline-offset:3px}
.lb-spin{display:none;width:17px;height:17px;margin-right:9px;border:2.5px solid rgba(255,255,255,.35);border-top-color:#fff;border-radius:50%;
  animation:lb-rot .7s linear infinite}
.lb-ok{display:none;margin-right:7px}
.lb[aria-busy="true"]{cursor:progress;filter:saturate(.7)}
.lb[aria-busy="true"] .lb-spin{display:inline-block}
.lb[data-state="done"]{background:#147a3a}
.lb[data-state="done"] .lb-ok{display:inline}
.lb-label{position:relative}
/* progress variant: a darker layer grows across the button while it is busy */
.lb-bar::after{content:"";position:absolute;left:0;top:0;bottom:0;width:0;background:rgba(15,23,42,.2)}
.lb-bar[aria-busy="true"]::after{animation:lb-fill 2s linear forwards}
.lb-sr{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
@keyframes lb-rot{to{transform:rotate(360deg)}}
@keyframes lb-fill{to{width:100%}}
@media (prefers-reduced-motion:reduce){.lb{transition:none}.lb-spin{animation-duration:1.5s}.lb-bar[aria-busy="true"]::after{animation:none}}
JavaScript
/* Loading buttons: aria-busy while the task runs, data-state="done" afterwards, and a polite status line for screen readers. */
document.querySelectorAll("[data-loading-btn]").forEach(function (btn) {
  var label = btn.querySelector("[data-label]");
  var idle = label.textContent;
  var status = document.querySelector("[data-loading-status]");
  btn.addEventListener("click", function () {
    if (btn.getAttribute("aria-busy") === "true" || btn.dataset.state === "done") return;
    btn.setAttribute("aria-busy", "true");
    label.textContent = "Working…";
    status.textContent = idle + ": working…";
    setTimeout(function () { // replace the timer with your real request
      btn.removeAttribute("aria-busy");
      btn.dataset.state = "done";
      label.textContent = "Done";
      status.textContent = idle + ": done.";
      setTimeout(function () { delete btn.dataset.state; label.textContent = idle; }, 1600);
    }, 2000);
  });
});

Bootstrap 5 code

Requires: Bootstrap 5.3.8 CSS

HTML
<button type="button" class="btn btn-load d-inline-flex align-items-center justify-content-center" data-loading-btn><span class="spinner-border" aria-hidden="true"></span><span class="btn-load-ok" aria-hidden="true">✓</span><span data-label>Save changes</span></button>
<button type="button" class="btn btn-load btn-load-bar d-inline-flex align-items-center justify-content-center" data-loading-btn><span class="spinner-border" aria-hidden="true"></span><span class="btn-load-ok" aria-hidden="true">✓</span><span data-label>Upload file</span></button>
<p class="visually-hidden" role="status" data-loading-status></p>
CSS
/* .btn coloured through --bs-btn-* variables, with Bootstrap's .spinner-border resized through --bs-spinner-* variables. */
.btn-load{--bs-btn-padding-x:32px;--bs-btn-padding-y:14px;--bs-btn-font-size:.92rem;--bs-btn-font-weight:700;--bs-btn-line-height:1.25;
  --bs-btn-border-width:0;--bs-btn-border-radius:12px;--bs-btn-focus-box-shadow:none;
  --bs-btn-color:#fff;--bs-btn-hover-color:#fff;--bs-btn-active-color:#fff;
  --bs-btn-bg:#2563eb;--bs-btn-hover-bg:#2563eb;--bs-btn-active-bg:#2563eb;
  position:relative;overflow:hidden;min-width:190px;transition:background-color .2s,filter .15s}
.btn-load:hover{filter:brightness(1.08)}
.btn-load:focus-visible{outline:2px solid #2563eb;outline-offset:3px}
.btn-load .spinner-border{--bs-spinner-width:17px;--bs-spinner-height:17px;--bs-spinner-border-width:2.5px;--bs-spinner-animation-speed:.7s;
  display:none;margin-right:9px;border-color:rgba(255,255,255,.35);border-top-color:#fff}
.btn-load .btn-load-ok{display:none;margin-right:7px}
.btn-load[aria-busy="true"]{cursor:progress;filter:saturate(.7)}
.btn-load[aria-busy="true"] .spinner-border{display:inline-block}
.btn-load[data-state="done"]{--bs-btn-bg:#147a3a;--bs-btn-hover-bg:#147a3a;--bs-btn-active-bg:#147a3a}
.btn-load[data-state="done"] .btn-load-ok{display:inline}
.btn-load [data-label]{position:relative}
.btn-load-bar::after{content:"";position:absolute;left:0;top:0;bottom:0;width:0;background:rgba(15,23,42,.2)}
.btn-load-bar[aria-busy="true"]::after{animation:lb-fill 2s linear forwards}
@keyframes lb-fill{to{width:100%}}
@media (prefers-reduced-motion:reduce){.btn-load{transition:none}.btn-load .spinner-border{--bs-spinner-animation-speed:1.5s}.btn-load-bar[aria-busy="true"]::after{animation:none}}
JavaScript
/* Loading buttons: aria-busy while the task runs, data-state="done" afterwards, and a polite status line for screen readers. */
document.querySelectorAll("[data-loading-btn]").forEach(function (btn) {
  var label = btn.querySelector("[data-label]");
  var idle = label.textContent;
  var status = document.querySelector("[data-loading-status]");
  btn.addEventListener("click", function () {
    if (btn.getAttribute("aria-busy") === "true" || btn.dataset.state === "done") return;
    btn.setAttribute("aria-busy", "true");
    label.textContent = "Working…";
    status.textContent = idle + ": working…";
    setTimeout(function () { // replace the timer with your real request
      btn.removeAttribute("aria-busy");
      btn.dataset.state = "done";
      label.textContent = "Done";
      status.textContent = idle + ": done.";
      setTimeout(function () { delete btn.dataset.state; label.textContent = idle; }, 1600);
    }, 2000);
  });
});

Tailwind 4 code

Requires: Tailwind CSS 4

HTML
<button type="button" class="group relative inline-flex min-w-[190px] cursor-pointer items-center justify-center overflow-hidden rounded-xl bg-[#2563eb] px-8 py-3.5 text-[.92rem] leading-[1.25] font-bold text-white transition-[background-color,filter] duration-150 hover:brightness-108 focus-visible:outline-2 focus-visible:outline-offset-3 focus-visible:outline-[#2563eb] aria-busy:cursor-progress aria-busy:saturate-70 data-[state=done]:bg-[#147a3a] motion-reduce:transition-none" data-loading-btn><span class="animate-lb-spin mr-[9px] hidden size-[17px] rounded-full border-[2.5px] border-white/35 border-t-white group-aria-busy:inline-block motion-reduce:animate-[lb-rot_1.5s_linear_infinite]" aria-hidden="true"></span><span class="mr-[7px] hidden group-data-[state=done]:inline" aria-hidden="true">✓</span><span class="relative" data-label>Save changes</span></button>
<button type="button" class="group relative inline-flex min-w-[190px] cursor-pointer items-center justify-center overflow-hidden rounded-xl bg-[#2563eb] px-8 py-3.5 text-[.92rem] leading-[1.25] font-bold text-white transition-[background-color,filter] duration-150 hover:brightness-108 focus-visible:outline-2 focus-visible:outline-offset-3 focus-visible:outline-[#2563eb] aria-busy:cursor-progress aria-busy:saturate-70 data-[state=done]:bg-[#147a3a] motion-reduce:transition-none after:absolute after:inset-y-0 after:left-0 after:w-0 after:bg-[rgba(15,23,42,.2)] aria-busy:after:animate-lb-fill motion-reduce:aria-busy:after:animate-none" data-loading-btn><span class="animate-lb-spin mr-[9px] hidden size-[17px] rounded-full border-[2.5px] border-white/35 border-t-white group-aria-busy:inline-block motion-reduce:animate-[lb-rot_1.5s_linear_infinite]" aria-hidden="true"></span><span class="mr-[7px] hidden group-data-[state=done]:inline" aria-hidden="true">✓</span><span class="relative" data-label>Upload file</span></button>
<p class="sr-only" role="status" data-loading-status></p>
CSS
@theme {
  --animate-lb-spin: lb-rot .7s linear infinite;
  --animate-lb-fill: lb-fill 2s linear forwards;
  @keyframes lb-rot { to { transform: rotate(360deg); } }
  @keyframes lb-fill { to { width: 100%; } }
}
JavaScript
/* Loading buttons: aria-busy while the task runs, data-state="done" afterwards, and a polite status line for screen readers. */
document.querySelectorAll("[data-loading-btn]").forEach(function (btn) {
  var label = btn.querySelector("[data-label]");
  var idle = label.textContent;
  var status = document.querySelector("[data-loading-status]");
  btn.addEventListener("click", function () {
    if (btn.getAttribute("aria-busy") === "true" || btn.dataset.state === "done") return;
    btn.setAttribute("aria-busy", "true");
    label.textContent = "Working…";
    status.textContent = idle + ": working…";
    setTimeout(function () { // replace the timer with your real request
      btn.removeAttribute("aria-busy");
      btn.dataset.state = "done";
      label.textContent = "Done";
      status.textContent = idle + ": done.";
      setTimeout(function () { delete btn.dataset.state; label.textContent = idle; }, 1600);
    }, 2000);
  });
});

About this set

Two submit-style buttons that show work in progress and then success. Save changes shows a spinning ring next to the label, which changes to Working while the task runs; afterwards the button turns green with a tick and the word Done, then returns to its idle label. Upload file does the same and also fills the button with a darker layer from left to right over two seconds, a simple progress effect for uploads and exports. The demo fakes the task with a timer, and the script marks the one line to replace with your real request. State lives in attributes rather than classes: the script sets aria-busy=”true” while working and data-state=”done” on success, and the CSS shows the spinner, tick and colours from those attributes, so the same script drives the plain CSS, Bootstrap and Tailwind versions. The button keeps focus the whole time and ignores repeat clicks while busy, and a visually hidden status line announces the progress to screen readers. Use it for saves, uploads, payments and any action that takes more than a moment. The Bootstrap version uses .spinner-border, resized through its –bs-spinner-* variables.

What’s included

  • Spinner, success tick and idle states driven by aria-busy and data-state
  • Progress fill variant for uploads and exports
  • One script shared by all three versions
  • Repeat clicks ignored while the task runs
  • Polite live region announces working and done

Accessibility

While busy each button carries aria-busy="true" and its visible label changes, and a role="status" line announces, for example, Save changes: working and then Save changes: done. The button is never disabled, so keyboard focus stays on it, and focus shows a 2px blue outline. Under prefers-reduced-motion the progress fill is removed and the spinner slows to 1.5 seconds per turn.

Customise it

Change the idle colour on .lb (or --bs-btn-bg on .btn-load in Bootstrap, bg-[#2563eb] in Tailwind) and the success colour in the [data-state="done"] rule. The spinner size is set on .lb-spin, through --bs-spinner-width in Bootstrap, and by size-[17px] in Tailwind.