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.
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.
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.
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.
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.
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.
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.
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.
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.