How to Create a Loading Button in CSS
This recipe builds a button that disables itself and shows a spinner while an async action runs, then restores its normal state once the action finishes.
The problem: double-submits and silent waiting
A button that looks the same before and during a network request gives the user no signal that anything happened. Two things go wrong as a result: the user, unsure whether the click registered, clicks again — sometimes firing the same request two or three times — and while waiting, they have no idea whether the app is working or frozen. A loading button solves both problems at once: it removes the ability to click again the instant the action starts, and it shows a visual cue (a spinner, a text change, or both) for as long as the action is in flight.
- Disable the button the moment it is clicked, before the async call even starts
- Show a loading indicator — a spinner, a changed label, or both — for the duration of the request
- Re-enable the button and remove the loading indicator once the action settles, whether it succeeds or fails
- Keep the button's footprint stable so it doesn't resize or jump when the label or spinner appears
Example
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Loading Button</title>
<style>
body {
font-family: system-ui, sans-serif;
padding: 40px;
background: #f5f5f7;
}
.btn {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-width: 140px;
padding: 10px 20px;
font-size: 16px;
font-weight: 600;
color: #fff;
background: #198754;
border: none;
border-radius: 8px;
cursor: pointer;
}
.btn[disabled] {
background: #6c9c82;
cursor: not-allowed;
}
.spinner {
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.4);
border-top-color: #fff;
border-radius: 50%;
animation: spin 0.7s linear infinite;
display: none;
}
.btn[aria-busy="true"] .spinner {
display: inline-block;
}
.btn[aria-busy="true"] .btn-label {
opacity: 0.85;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>
</head>
<body>
<button class="btn" id="submitBtn" type="button">
<span class="spinner" aria-hidden="true"></span>
<span class="btn-label">Submit</span>
</button>
<script>
const button = document.getElementById('submitBtn');
const label = button.querySelector('.btn-label');
function fakeApiCall() {
return new Promise((resolve) => setTimeout(resolve, 1800));
}
button.addEventListener('click', async () => {
button.disabled = true;
button.setAttribute('aria-busy', 'true');
label.textContent = 'Submitting…';
try {
await fakeApiCall();
label.textContent = 'Submitted!';
} catch (error) {
label.textContent = 'Try again';
} finally {
button.disabled = false;
button.removeAttribute('aria-busy');
setTimeout(() => { label.textContent = 'Submit'; }, 1500);
}
});
</script>
</body>
</html>The disabled attribute stops clicks and removes the button from the tab order's interactive state, but it communicates nothing to assistive technology about why the button is unavailable. Adding aria-busy="true" while the action runs tells screen readers that this element is in the middle of updating, and pairing it with a CSS attribute selector like .btn[aria-busy="true"] .spinner { display: inline-block; } lets the same attribute drive both the accessible state and the visual one from a single source of truth.
Example
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Loading Button with Fetch</title>
<style>
body {
font-family: system-ui, sans-serif;
padding: 40px;
background: #f5f5f7;
}
.btn {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-width: 160px;
padding: 10px 20px;
font-size: 16px;
font-weight: 600;
color: #fff;
background: #0d6efd;
border: none;
border-radius: 8px;
cursor: pointer;
transition: background-color 0.15s ease;
}
.btn[disabled] {
background: #6ea8fe;
cursor: not-allowed;
}
.btn.error {
background: #dc3545;
}
.spinner {
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.4);
border-top-color: #fff;
border-radius: 50%;
animation: spin 0.7s linear infinite;
display: none;
}
.btn[aria-busy="true"] .spinner {
display: inline-block;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>
</head>
<body>
<button class="btn" id="loadBtn" type="button">
<span class="spinner" aria-hidden="true"></span>
<span class="btn-label">Load data</span>
</button>
<script>
const button = document.getElementById('loadBtn');
const label = button.querySelector('.btn-label');
// Replace this URL with your real endpoint.
async function loadData() {
button.disabled = true;
button.classList.remove('error');
button.setAttribute('aria-busy', 'true');
label.textContent = 'Loading…';
try {
const response = await fetch('/api/data');
if (!response.ok) throw new Error('Request failed');
await response.json();
label.textContent = 'Loaded!';
} catch (error) {
button.classList.add('error');
label.textContent = 'Failed — retry';
} finally {
button.disabled = false;
button.removeAttribute('aria-busy');
}
}
button.addEventListener('click', loadData);
</script>
</body>
</html>Avoiding layout shift and choosing good states
- Resetting the button only in the success branch and forgetting the error branch, so a failed request leaves it stuck
- Letting the button's width change when the label text changes length, which shifts nearby content
- Hiding the label text completely during loading with no accessible replacement, so screen reader users get silence instead of progress
- Not disabling the button synchronously on click, leaving a brief window where a fast double-click still fires twice
A loading button is a small amount of code with an outsized effect on how trustworthy an interface feels: it turns an ambiguous click into a click the user can see was received, is being handled, and eventually finished.
Frequently Asked Questions
- How do I add a loading state to a button?
- Add a class that swaps the label for a spinner, and set the
disabledattribute so it cannot be clicked twice. Fix the button's width first, or it will visibly shrink when the text is replaced. - How do I stop a button being submitted twice?
- Disable it in the submit handler before the request goes out, and re-enable it when the response returns or fails. Relying on the visual loading state alone still lets a fast double-click through.