SolutionsCase StudiesAI ToolsResourcesAboutContact
Get Your Free AI Automation Audit Book a Free 30-Minute Strategy Call
Playground~/frontend Open editor

~/css/loading-buttons

7 CSS Loading Buttons

0 Pure CSS7 with JSPublished 2026-10-05Free · no sign-up

Seven loading states for buttons that start slow work: an inline spinner, a fill that shows upload progress, three bouncing dots, a button that morphs into a spinner and then a tick, a calm shimmer, an icon button with a progress ring and a form that ignores double submits.

01. Button with Inline Spinner

LoadingSpinner

Clicking shows a spinner, changes the label to Saving and sets aria-busy and aria-disabled. A minimum width stops the button from jumping as the label changes. After a pretend two-second request it returns to normal.

View code
<button class="lb1"><span class="sp" aria-hidden="true"></span><span class="t">Save changes</span></button>
.lb1 { display: inline-flex; align-items: center; justify-content: center; gap: 10px; min-width: 170px; padding: 13px 22px; border: 0; border-radius: 10px; background: #2563eb; color: #fff; font: 700 15px Inter, system-ui, sans-serif; cursor: pointer; }
.lb1 .sp { display: none; width: 16px; height: 16px; border: 2px solid rgba(255,255,255,.4); border-top-color: #fff; border-radius: 50%; animation: lb1 .7s linear infinite; }
.lb1[aria-busy="true"] { background: #3b6fe0; cursor: progress; }
.lb1[aria-busy="true"] .sp { display: block; }
@keyframes lb1 { to { transform: rotate(360deg); } }
.lb1:focus-visible { outline: 3px solid #93c5fd; outline-offset: 3px; }
@media (prefers-reduced-motion: reduce) { .lb1 .sp { animation-duration: 2s; } }
var b = document.querySelector('.lb1'), t = b.querySelector('.t');
b.addEventListener('click', function () {
  if (b.getAttribute('aria-busy') === 'true') return;
  b.setAttribute('aria-busy', 'true'); b.setAttribute('aria-disabled', 'true'); t.textContent = 'Saving…';
  setTimeout(function () { b.removeAttribute('aria-busy'); b.removeAttribute('aria-disabled'); t.textContent = 'Save changes'; }, 2000);
});

02. Button That Fills as Progress

LoadingProgress

The button background fills from left to right like a progress bar while an upload runs, then turns green with Done. The percentage is in the label so it can be read, and the label is a polite live region so the finish is announced.

View code
<button class="lb2"><span class="fill" aria-hidden="true"></span><span class="t" aria-live="polite">Upload file</span></button>
.lb2 { box-sizing: border-box; --p: 0%; position: relative; overflow: hidden; width: 200px; max-width: 100%; padding: 14px 0; border: 1px solid #3b4a7a; border-radius: 10px; background: #141a2e; color: #e6ebff; font: 700 15px Inter, system-ui, sans-serif; cursor: pointer; }
.lb2 .fill { position: absolute; inset: 0; width: var(--p); background: #4f46e5; transition: width .2s linear; }
.lb2 .t { position: relative; }
.lb2.done { border-color: #16a34a; }
.lb2.done .fill { background: #15803d; }
.lb2:focus-visible { outline: 2px solid #a5b4fc; outline-offset: 3px; }
var b = document.querySelector('.lb2'), t = b.querySelector('.t');
b.addEventListener('click', function () {
  if (b.dataset.run) return;
  b.dataset.run = 1; b.classList.remove('done');
  var p = 0, id = setInterval(function () {
    p += 10; b.style.setProperty('--p', p + '%'); t.textContent = 'Uploading ' + p + '%';
    if (p >= 100) { clearInterval(id); b.classList.add('done'); t.textContent = 'Done ✓'; delete b.dataset.run; }
  }, 180);
});

03. Three Dots Loading State

LoadingDots

The label is swapped for three bouncing dots while waiting, and a visually hidden Sending message keeps the button's name meaningful for screen readers. The fixed width means the layout does not jump when the label disappears.

View code
<button class="lb3"><span class="t">Send message</span><span class="d" aria-hidden="true"><i></i><i></i><i></i></span><span class="sr">Sending</span></button>
.lb3 { position: relative; width: 180px; max-width: 100%; height: 50px; border: 0; border-radius: 999px; background: #111827; color: #fff; font: 700 15px Inter, system-ui, sans-serif; cursor: pointer; }
.lb3 .d, .lb3 .sr { display: none; }
.lb3 .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
.lb3.busy .t { display: none; }
.lb3.busy .d { display: inline-flex; gap: 6px; }
.lb3.busy .sr { display: block; }
.lb3 i { width: 8px; height: 8px; border-radius: 50%; background: #fff; animation: lb3 1s ease-in-out infinite; }
.lb3 i:nth-child(2) { animation-delay: .15s; } .lb3 i:nth-child(3) { animation-delay: .3s; }
@keyframes lb3 { 0%, 80%, 100% { transform: translateY(0); opacity: .5; } 40% { transform: translateY(-6px); opacity: 1; } }
.lb3:focus-visible { outline: 3px solid #9ca3af; outline-offset: 3px; }
@media (prefers-reduced-motion: reduce) { .lb3 i { animation: none; } }
var b = document.querySelector('.lb3');
b.addEventListener('click', function () {
  if (b.classList.contains('busy')) return;
  b.classList.add('busy'); b.setAttribute('aria-disabled', 'true');
  setTimeout(function () { b.classList.remove('busy'); b.removeAttribute('aria-disabled'); }, 2200);
});

04. Loading to Success Morph

LoadingSuccess

A wide button shrinks into a circle with a spinner, then shows a tick and expands again. Width and border-radius transition together, which works because the button has an explicit width in each state. The result is announced through a live region below.

View code
<div class="lb4"><button type="button"><span class="t">Pay $24.00</span></button><p class="msg" aria-live="polite"></p></div>
.lb4 { display: grid; justify-items: center; gap: 10px; font: 700 15px Inter, system-ui, sans-serif; }
.lb4 button { width: 200px; max-width: 100%; height: 52px; border: 0; border-radius: 12px; background: #15803d; color: #fff; font: inherit; cursor: pointer; transition: width .35s ease, border-radius .35s ease; }
.lb4 button.busy, .lb4 button.ok { width: 52px; border-radius: 26px; }
.lb4 button.busy .t, .lb4 button.ok .t { display: none; }
.lb4 button.busy::after { content: ""; display: block; width: 20px; height: 20px; margin: auto; border: 3px solid rgba(255,255,255,.4); border-top-color: #fff; border-radius: 50%; animation: lb4 .7s linear infinite; }
.lb4 button.ok::after { content: "✓"; font-size: 22px; }
.lb4 .msg { margin: 0; min-height: 1.2em; color: #86efac; font-weight: 600; font-size: 14px; }
@keyframes lb4 { to { transform: rotate(360deg); } }
.lb4 button:focus-visible { outline: 3px solid #86efac; outline-offset: 3px; }
@media (prefers-reduced-motion: reduce) { .lb4 button { transition: none; } }
var b = document.querySelector('.lb4 button'), m = document.querySelector('.lb4 .msg');
b.addEventListener('click', function () {
  if (b.className) return;
  b.className = 'busy'; b.setAttribute('aria-label', 'Processing payment'); m.textContent = '';
  setTimeout(function () { b.className = 'ok'; b.setAttribute('aria-label', 'Payment complete'); m.textContent = 'Payment complete'; }, 1600);
  setTimeout(function () { b.className = ''; b.removeAttribute('aria-label'); m.textContent = ''; }, 3600);
});

05. Shimmer While Waiting

LoadingShimmer

While busy, a light shimmer sweeps across the button, a calm alternative to a spinner for short waits. Your fetch code only has to toggle one class and the disabled property, and the label changes to say what is happening. Focus returns to the button when it is done.

View code
<button class="lb5">Refresh data</button>
.lb5 { position: relative; overflow: hidden; padding: 14px 26px; border: 1px solid #d0d5dd; border-radius: 10px; background: #fff; color: #1d2939; font: 700 15px Inter, system-ui, sans-serif; cursor: pointer; }
.lb5.is-loading { color: #475467; cursor: progress; }
.lb5.is-loading::after { content: ""; position: absolute; inset: 0; background: linear-gradient(90deg, transparent, rgba(37,99,235,.18), transparent); transform: translateX(-100%); animation: lb5 1.1s ease-in-out infinite; }
@keyframes lb5 { to { transform: translateX(100%); } }
.lb5:focus-visible { outline: 3px solid #93c5fd; outline-offset: 3px; }
@media (prefers-reduced-motion: reduce) { .lb5.is-loading::after { animation-duration: 3s; } }
var b = document.querySelector('.lb5');
b.addEventListener('click', function () {
  b.classList.add('is-loading'); b.disabled = true; b.textContent = 'Refreshing…';
  setTimeout(function () { b.classList.remove('is-loading'); b.disabled = false; b.textContent = 'Refresh data'; b.focus(); }, 2000);
});

06. Progress Ring Icon Button

LoadingIcon

A round download button whose border becomes a progress ring drawn with a conic gradient, so no SVG ring is needed. The fill comes from a custom property, and the aria-label is updated with the progress so the state is available to screen readers as well as shown.

View code
<button class="lb6" aria-label="Download report"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 4v11M7 10l5 5 5-5M5 20h14"/></svg></button>
.lb6 { --p: 0; position: relative; display: grid; place-items: center; width: 64px; height: 64px; border: 0; border-radius: 50%; background: conic-gradient(#38bdf8 calc(var(--p) * 1%), #26304e 0); cursor: pointer; }
.lb6::before { content: ""; position: absolute; inset: 4px; border-radius: 50%; background: #10162a; }
.lb6 svg { position: relative; width: 26px; height: 26px; fill: none; stroke: #e0f2fe; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.lb6:focus-visible { outline: 2px solid #38bdf8; outline-offset: 4px; }
var b = document.querySelector('.lb6');
b.addEventListener('click', function () {
  if (b.dataset.run) return;
  b.dataset.run = 1;
  var p = 0, id = setInterval(function () {
    p += 5; b.style.setProperty('--p', p);
    b.setAttribute('aria-label', 'Downloading, ' + p + ' percent');
    if (p >= 100) { clearInterval(id); b.setAttribute('aria-label', 'Download complete'); delete b.dataset.run; }
  }, 90);
});

07. Busy Button That Blocks Double Submits

LoadingForms

A real form whose submit button turns busy on submit and ignores further clicks, which prevents double orders. It uses aria-disabled rather than disabled, so the button stays focusable and keyboard users do not lose their place.

View code
<form class="lb7"><label for="lb7e">Email</label><input id="lb7e" type="email" value="ana@example.com" required><button type="submit">Request invite</button><p aria-live="polite"></p></form>
.lb7 { display: grid; gap: 8px; width: 280px; max-width: 100%; box-sizing: border-box; font: 15px Inter, system-ui, sans-serif; color: #1d2939; }
.lb7 label { font-weight: 600; font-size: 14px; }
.lb7 input { min-width: 0; padding: 11px 12px; border: 1px solid #98a2b3; border-radius: 8px; font: inherit; }
.lb7 button { margin-top: 4px; padding: 12px; border: 0; border-radius: 8px; background: #0f766e; color: #fff; font: 700 15px Inter, system-ui, sans-serif; cursor: pointer; }
.lb7 button[aria-disabled="true"] { background: #4b8f8a; cursor: progress; }
.lb7 p { margin: 0; min-height: 1.3em; font-size: 14px; color: #0f766e; }
.lb7 :focus-visible { outline: 3px solid #5eead4; outline-offset: 2px; }
var f = document.querySelector('.lb7'), b = f.querySelector('button'), out = f.querySelector('p');
f.addEventListener('submit', function (e) {
  e.preventDefault();
  if (b.getAttribute('aria-disabled') === 'true') return;
  b.setAttribute('aria-disabled', 'true'); b.textContent = 'Sending…'; out.textContent = '';
  setTimeout(function () { b.removeAttribute('aria-disabled'); b.textContent = 'Request invite'; out.textContent = 'Invite requested. Check your inbox.'; }, 1800);
});

About these CSS Loading Buttons

Tell everyone it is busy

Changing the look is not enough. Change the label too (Saving…, Sending…), and set aria-busy="true" or update the accessible name, so screen reader users know the click worked.

aria-disabled versus disabled

The disabled attribute removes the button from the tab order, so a keyboard user's focus can drop to the page. aria-disabled="true" keeps it focusable; your script then ignores clicks while it is set.

Stop the layout jumping

A label that changes length makes the button resize and pushes things around. Give loading buttons a minimum or fixed width so the spinner or dots fit inside the same space.

Announce the result

When the work finishes, say so: a polite live region next to the button reading Payment complete or Invite requested lets everyone know the outcome without moving focus.

Frequently asked questions

Is the loading state real?

The demos fake the wait with setTimeout. Replace it with your fetch call and toggle the same classes and attributes.

Which loader should I use?

Spinners and dots suit unknown waits; a fill or ring suits uploads where you know the progress.

What about reduced motion?

The spinners slow down or stop under prefers-reduced-motion, and the text change still shows the state.