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.