webdevpuneet.com

Loading Button — Free HTML CSS JS Spinner Button Snippet

Loading Button · Buttons · Plain HTML, CSS & JS · Live preview

CategoryButtons

Copy & paste

Source Code

<div class="demo">
  <button class="btn" id="btn" onclick="triggerLoad()">
    <span class="spinner"></span>
    <span class="label">Save changes</span>
  </button>
  <p class="hint">Click to see the loading state</p>
</div>

Extend with AI

Take This Snippet Further With AI

The AI prompt for this one is for Patreon supporters ($3 a month, 7 days free), along with a site with no ads. Supporters enter the passcode once and it is remembered in this browser.

· Become a supporter

What's included

Features

CSS border-top-color spinner — no SVG, no icon library, just a div and a keyframe
display: none / display: block spinner toggle via .loading parent class selector
Three-state machine: idle, loading (.loading), success (.done)
Double-click prevention — early return if .loading or .done is already present
min-width: 150px prevents layout shift as labels change between states
cursor: not-allowed and opacity: 0.8 during loading state
justify-content: center keeps label and spinner centred at all sizes
Configurable loading duration (1800ms) and success display duration (2000ms)
15 lines of vanilla JavaScript — replaceable with fetch() in one step
Export as HTML file, React JSX, or React + Tailwind CSS

About this UI Snippet

Loading Button — CSS Spinner, Three-State Machine & Layout Stability

Screenshot of the Loading Button snippet rendered live

A loading button gives users immediate visual feedback that their action was received and is being processed. Without it, users click buttons multiple times assuming nothing happened — causing duplicate API calls, double submissions on a contact form, and double payments. This is one of the most impactful micro-interactions in web development: a single CSS animation and 15 lines of JavaScript that prevent real production bugs.

This snippet gives you a complete loading button in plain HTML, CSS, and vanilla JavaScript. Click it in the preview to see the full cycle: idle state → spinner + "Saving…" → green success + "✓ Saved!" → reset to idle.

How the CSS spinner works

The spinner element is a 14×14px div with border: 2px solid rgba(255,255,255,0.4) — a faded white border on all four sides. border-top-color: #fff overrides the top border to full opacity. A @keyframes spin animation rotates the element 360 degrees at 0.65s linear infinite. The three faded sides form the trailing arc and the full-opacity top border is the leading head. This technique requires no SVG, no icon library, and no images — just a div and a keyframe, the same approach as the standalone loading dots.

By default .spinner { display: none }. The selector .loading .spinner { display: block } activates it only when the parent button has the .loading class.

The three-state machine

The button cycles through three states managed by class names:

Idle (default): no extra class, normal background, label reads "Save changes", spinner hidden, cursor pointer.

Loading (.loading): spinner visible, label changes to "Saving…", opacity: 0.8, cursor: not-allowed. The function adds this class immediately on click so feedback is instant.

Done (.done): spinner hidden, background changes to green (#16a34a), label changes to "✓ Saved!". Applied after a 1.8-second timeout simulating an API response. For a more celebratory confirmation, trigger a confetti button instead.

After 2 seconds on the Done state the button resets to Idle. Both .loading and .done guard against re-triggering — the function returns early if either is present.

Why min-width prevents layout shift

The button has min-width: 150px. Without it, the button width would change as the label switches between "Save changes" (12 chars), "Saving…" (7 chars), and "✓ Saved!" (8 chars), causing the surrounding layout to shift on every state change. The min-width locks the button to its widest natural size and justify-content: center keeps the content centred within it.

Connecting to a real API call

Replace the setTimeout in triggerLoad() with your actual fetch() call. Move the success code block into .then() and add a .catch() handler with an error state. For async/await, wrap the fetch in a try/catch and place the success/error logic in the respective branches.

Adding a fourth error state

Add .btn.error { background: #dc2626; } to the CSS. In the catch block, add btn.classList.add('error') and set the label to "Error — try again". Reset to idle after 3 seconds with setTimeout.

Saving your customised version

After editing, click "Save as" in the editor header, type a name, and press Enter. Saves to IndexedDB and appears in the Saved tab.

Step by step

How to Use

  1. 1
    Click the button in the previewClick "Save changes" to see the full idle → spinner + Saving → green Saved! → reset cycle play out in real time.
  2. 2
    Change the labelsIn the JS panel, find "Save changes", "Saving…", and "✓ Saved!" and replace with your own action-specific labels.
  3. 3
    Adjust the timingChange 1800 (loading duration in ms) and 2000 (success display duration) to match your expected API response time.
  4. 4
    Connect to a real API callReplace the setTimeout in triggerLoad() with your fetch() call. Move the success block into .then() and add .catch() with an error state.
  5. 5
    Customise the coloursUpdate the button background (#6366f1), hover colour (#4f46e5), and success colour (#16a34a) in the CSS panel to match your brand.
  6. 6
    Export in your formatClick "HTML" for a standalone file, "JSX" for a React component, or "Tailwind" for a React + Tailwind version.

Real-world uses

Common Use Cases

Form submission and settings save
Show users their form is processing with a spinner, then confirm success. Prevents impatient double-submissions that cause duplicate database records.
Payment and checkout buttons
The most critical use case. Blocking re-clicks during payment processing prevents duplicate charges. The loading state makes waiting tolerable.
API action buttons
Follow, like, add to cart, subscribe — any button that triggers a network request benefits from a loading state and confirmation feedback.
File upload triggers
Buttons that start file uploads where progress feedback prevents users from assuming the action failed and clicking again.
Learn CSS-only spinner technique
Edit the border values and animation speed in the CSS panel to understand how a single rotating div with one full-opacity border creates the spinner arc.
Migrate to a React async button
Use the JSX export and replace the class-based state machine with useState. Set loading true on click, await the fetch, then set success or error state.
Related: Segmented Toggle
See the Segmented Toggle for a related buttons pattern worth pairing with this one.

Got questions?

Frequently Asked Questions

The spinner div has border: 2px solid rgba(255,255,255,0.4) on all four sides — a faded ring. border-top-color: #fff overrides the top to full opacity. A @keyframes animation rotates the element 360 degrees. The three faded sides form the trailing arc and the bright top side is the spinning head. No SVG, no image — just a div and a keyframe.

In triggerLoad(), replace the outer setTimeout with your fetch() call. Move btn.classList.remove("loading"); btn.classList.add("done") into the .then() handler. Add a .catch() block that removes .loading, sets a .error class, and updates the label to an error message.

Add .btn.error { background: #dc2626; } to the CSS. In the .catch() block, add btn.classList.add("error") and set label.textContent to "Error — try again". Use setTimeout to remove .error and reset the label after 3 seconds so the user can retry.

The function starts with: if (btn.classList.contains("loading") || btn.classList.contains("done")) return; This exits immediately if the button is already loading or showing success, blocking all subsequent clicks until the cycle completes and the classes are removed.

The label changes between "Save changes", "Saving…", and "✓ Saved!" — three strings of different lengths. Without min-width: 150px, the button width would change with each label, shifting surrounding layout elements. The fixed minimum width locks the button to its widest natural size.

Yes. The current implementation uses document.getElementById("btn") which targets a specific element. To reuse across multiple buttons, refactor triggerLoad() to accept the button element as a parameter, or use event delegation with this inside an onclick handler.

Yes. Click "JSX" to download a React component. Replace the class-based state machine with useState — const [state, setState] = useState("idle"). Set state to "loading" on click, await your fetch call, then setState to "success" or "error". Use the state value to control the className, label text, and spinner visibility.