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.

Note: Always put the reset logic in a finally block (or .finally() on a promise chain), not just after the success path. If you only re-enable the button when the request succeeds, a network error or a thrown exception leaves it permanently disabled and spinning — a bug that is easy to miss in testing because happy-path testing never hits it.

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

Statedisabledaria-busyVisual
Idlefalsenot setNormal color, label text, no spinner
LoadingtruetrueDimmed/disabled style, spinner visible, label often changes to a verb+ing form
SuccessfalseremovedBrief confirmation label (e.g. "Saved!") before reverting
ErrorfalseremovedError color/label so the user knows to retry
  • 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
Note: If the button's text doesn't change in an announcement-worthy way, add a small aria-live="polite" region near it that says "Loading" and then "Done," so screen reader users get the same feedback sighted users get from the spinner.

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