20 CSS Loading Buttons

20 hand-coded CSS loading buttons for checkout flows, dashboards, upload forms and any action that waits on a server. Covers the inline spinner swap, the width jump when a label changes, double-submit guarding, the aria-busy contract screen readers need, and the states most builds skip — retry after failure, optimistic save, and an undo window instead of a confirm dialog. Scoped under .lb-NN for no-collision pasting, prefers-reduced-motion guarded, framework-agnostic.

5 pure CSS15 light JSPublished

Related51 CSS Buttons20 CSS Loaders20 CSS Form Validation30 CSS Notifications

Button with Inline Spinner Swap — preview
01 / 20Light JS

Button with Inline Spinner Swap

The reference build the rest of the collection reuses: the label crossfades into a spinner that sits exactly where the text was, both stacked in one grid cell so nothing reflows. One data-state attribute on the button carries idle, pending, done and error, and attribute selectors do all the styling. Reach for it whenever a click starts work the user has to wait for.

Published

Fixed Width Loading Button No Layout Shift — preview
02 / 20Light JS

Fixed Width Loading Button No Layout Shift

The width problem stated plainly: Save is narrower than Saving…, so a naive label swap makes the button jump and shoves whatever sits beside it. Three fixes run side by side against the broken control — both labels stacked in one grid cell, a min-inline-size floor in ch units, and reserving the spinner box with visibility instead of display. Pick whichever fits your markup.

Published

Disabled Pending State Button — preview
03 / 20Light JS

Disabled Pending State Button

Guarding against the double submit: set the disabled property on the element itself, not a class, so the browser blocks the second activation including Enter and space. Two buttons run side by side — one properly disabled, one guarded only by a class — with a live counter showing how many requests each one actually fired. The class-only version double posts every time.

Published

Accessible Loading Button with ARIA Live — preview
04 / 20Light JS

Accessible Loading Button with ARIA Live

The contract a spinner alone does not satisfy. While the request runs the button carries aria-busy, the state change is announced through a polite live region, and the failure path is announced through role=alert so it interrupts. It also fixes the disabled trap from the previous demo: aria-disabled plus a no-op handler keeps the control focusable while still refusing the work.

Published

Debounced Minimum Spinner Duration — preview
05 / 20Light JS

Debounced Minimum Spinner Duration

A 90 ms response makes a spinner appear and vanish inside three frames, which reads as a glitch rather than progress. The fix is two thresholds: do not show the pending state at all under about 150 ms, and once shown hold it for a floor of about 400 ms. Fire a fast request and a slow one from the same panel and the difference is immediately obvious.

Published

Success Checkmark Morph Button — preview
06 / 20Light JS

Success Checkmark Morph Button

The spinner's own stroke becomes the checkmark: one SVG path, animated with stroke-dasharray and stroke-dashoffset, so there is no spinner element swapped out for a tick element. The ring shortens, rotates into place and draws the check, holds for a readable beat, then reverts to idle. The failure branch draws a cross along the same path budget.

Published

Error State with Shake and Retry — preview
07 / 20Light JS

Error State with Shake and Retry

Failure has to be as legible as success. The button takes a red surface, a short horizontal shake built purely from transform, and a label that becomes an explicit retry affordance instead of quietly reverting to idle. Under reduced motion the shake is replaced by a colour and label change rather than removed, because the feedback is the message.

Published

Background Progress Fill Button — preview
08 / 20Light JS

Background Progress Fill Button

The button's own background fills from left to right as the work completes. A single --progress custom property drives a hard-stop gradient, and because the property is registered with @property the browser can interpolate it, so each update glides instead of stepping. One style write per progress event; no extra elements, no width animation.

Published

Percentage Progress Upload Button — preview
09 / 20Light JS

Percentage Progress Upload Button

Numeric progress beside the fill, with tabular figures so the digits do not jitter as they climb, and a role=progressbar wrapper whose aria-valuenow stays in sync with what is painted. A toggle switches the same control to indeterminate, because when the total size is unknown a percentage is a lie and a plain spinner is the honest answer.

Published

Streaming Response Typing Button — preview
10 / 20Light JS

Streaming Response Typing Button

The modern async shape: a send button whose pending state is a typing indicator rather than a spinner, because the response streams in rather than arriving all at once. Three dots pulse inside the button while tokens land, an explicit stop control appears beside it so the user can interrupt, and a mid-stream failure leaves the partial text on screen with a retry.

Published

Checkout Pay Button with Pending Lock — preview
11 / 20Light JS

Checkout Pay Button with Pending Lock

The highest-stakes async button on the web. It locks for the full duration of the charge, states plainly that the window must stay open, holds a minimum visible duration so a fast authorisation cannot flicker, and never finds its own way back to idle — only an explicit success or an explicit decline moves it. Deliberately a payment button, not an add-to-cart button.

Published

Optimistic UI Save Changes Button — preview
12 / 20Light JS

Optimistic UI Save Changes Button

Show Saved immediately, sync in the background, and roll back visibly if the write fails. There is no spinner at all in the happy path, which is the point: the interface commits to the user's intent and reconciles afterwards. It suits low-stakes writes like a preference toggle, and it is the wrong pattern for anything you cannot un-say.

Published

Form Submit with Validation Error State — preview
13 / 20Light JS

Form Submit with Validation Error State

The round trip that is not a network failure: the spinner runs, the server rejects the value, and the button returns to interactive while the offending field is marked and described. The button's error state and the field's error state are two different messages — one says the request finished, the other says which value has to change.

Published

Infinite Scroll Load More Button — preview
14 / 20Light JS

Infinite Scroll Load More Button

A secondary outline button below a short list, with a dots pulse for the pending state, a retry path when the page fails to load, and an end-of-results state that replaces the control entirely rather than leaving a dead button on the page. The list itself stays deliberately plain — the button is the subject.

Published

Delete Button with Undo Window — preview
15 / 20Light JS

Delete Button with Undo Window

Destructive actions done the modern way: act immediately, then offer a timed undo with a visible countdown, instead of blocking the user on a confirm dialog first. The row leaves at once, a five-second window drains in view, and the commit only happens when the window closes. If that commit fails, the row comes back with an explanation.

Published

Ghost Outline Loading Button — preview
16 / 20Pure CSS

Ghost Outline Loading Button

Border-only at rest, with the pending state carried by the border itself: a dash travels the perimeter while the request runs, so the outline is the indicator and nothing inside the button moves. No script — an SVG rect stroke does the drawing, and a checkbox stands in for the request so the whole state machine is CSS.

Published

Icon Only Circular Loading Button — preview
17 / 20Pure CSS

Icon Only Circular Loading Button

A 44px circular control where the icon rotates out and a ring rotates in. The important part is not the animation: an icon-only button has no visible label, so its accessible name must change with the state or a screen reader user has no idea anything happened. Each state ships its own button element with its own name, swapped by CSS.

Published

Full Width Mobile Touch Button — preview
18 / 20Pure CSS

Full Width Mobile Touch Button

The bottom-anchored primary action: full-bleed, a generous touch target, and a thin indeterminate line along its top edge while the request runs. It respects env(safe-area-inset-bottom) so it clears the home indicator on a notched phone, and it holds its ground at 320px without creating horizontal overflow.

Published

Gradient Border Trace Loading Button — preview
19 / 20Pure CSS

Gradient Border Trace Loading Button

A conic gradient border that rotates while the request is pending and stops when it resolves, so the motion itself signals state rather than decorating the button. The angle is a registered custom property, which is the only way it can animate — an unregistered one silently does nothing, and that is the single most common reason this effect appears broken.

Published

Dark Mode Tokenized Loading Button — preview
20 / 20Pure CSS

Dark Mode Tokenized Loading Button

Every state colour — idle, pending, success, error — declared as a custom property, so rethemeing the whole state machine is a token swap rather than a rewrite. The light and dark switch inside the demo is native CSS with no script: a visually hidden checkbox and :has() rewrite the tokens on the demo root.

Published

FAQ

Frequently asked questions

How do I stop a button from resizing when it shows a loading state?
The label is the problem: "Save" is narrower than "Saving…", so swapping the text makes the button grow and shove whatever sits beside it. Three fixes work, and they suit different cases. Stack both labels in the same grid cell with grid-area: 1 / 1 and crossfade opacity between them — the button sizes to the wider of the two and never moves. Or set a min-inline-size in ch units, sized to the longest label the button will ever hold, which is simplest when the states are known up front. Or reserve the spinner's box with visibility: hidden rather than display: none, so the space exists before the spinner appears. What does not work is swapping textContent and hoping, which is the default approach and the reason so many production buttons visibly jump. The shift is not only ugly — it is a Cumulative Layout Shift event, and a button near other controls can push a mis-click onto the wrong target.
How do I prevent a double submit on a loading button?
Set the disabled property on the element itself, not a class that only changes appearance. A class with pointer-events: none stops the mouse but not the keyboard — the button still activates on Enter or Space, so a determined double-tap still fires twice. disabled makes the browser refuse activation from every input method, which is the whole point. There is a real tradeoff, though: a disabled element is removed from the tab order and silently drops focus, so a keyboard user who just pressed the button is dumped back at the top of the document with no idea what happened. When focus must persist, use aria-disabled="true" plus a guard clause that returns early from the handler — the button stays focusable and announces as unavailable, and your own code blocks the second call. Belt and braces is to also guard on the state itself: if data-state is already pending, return immediately.
How do I make a loading button accessible to screen readers?
A spinner is a visual-only signal — it fires no accessibility event, so a screen reader user gets silence while the page waits. Three things fix that. Put aria-busy="true" on the button while the request is in flight and clear it on resolution, which is the standard way to say "this control is working". Announce the outcome through a live region: an element with aria-live="polite" that you write "Changes saved" into on success. Use role="alert" for the failure path instead, because a failed save is urgent and polite announcements can be queued behind other speech. Beyond that, never rely on colour alone for the error state — red plus a label, or red plus an icon. And if the button shows determinate progress, it needs role="progressbar" with aria-valuenow, aria-valuemin and aria-valuemax kept in sync, or the percentage is invisible to assistive technology.
Why does my loading spinner flash and look broken?
Because the response came back faster than a human can register the spinner. A request that resolves in 90 ms makes the spinner appear and vanish inside a couple of frames, which reads as a glitch rather than as feedback — users report it as "the button flickered", not "that was fast". The fix is two thresholds. Do not show the pending state at all for the first ~150 ms: if the work finishes inside that window, the button simply goes straight to its result and the interaction feels instant. If the spinner does appear, hold it for a floor of ~400 ms even when the response arrives sooner, so it is on screen long enough to read. Both numbers are judgement calls rather than standards, but the shape is well established and it is what makes fast interfaces feel calm instead of twitchy. The same reasoning applies to skeletons and progress bars — anything that appears and disappears too quickly costs more than it communicates.
What is optimistic UI and when should I use it for a save button?
Optimistic UI shows the success state immediately, before the server has confirmed anything, then reconciles when the real answer arrives. A settings toggle that reads "Saved" the instant you flip it feels dramatically faster than one that spins for 400 ms first, and in the overwhelming majority of cases the write does succeed. The honest tradeoff is that it occasionally lies. If the request fails you must roll the interface back visibly and say so — a silent revert is worse than a spinner, because the user has already moved on believing the change stuck. That makes it a good fit for low-stakes, easily-reversed writes: preferences, toggles, likes, draft edits. It is a bad fit for anything the user cannot undo or would be harmed by mis-reporting — payments, deletions, submissions with legal weight. For those, make the wait explicit and let the button stay pending until the server actually answers.
Should I use a confirmation dialog or an undo window for delete?
An undo window is usually better, and it is what most modern products have moved to. A confirmation dialog interrupts every deletion to guard against the rare mistake, which means the frequent correct action pays the cost of the infrequent wrong one — and people learn to dismiss the dialog reflexively, so it stops protecting anything. An undo window inverts that: the delete happens immediately, the row disappears, and a timed affordance offers to put it back. The common case is fast, and the mistake is still recoverable. Two details make it work. The countdown must be visible so the user knows the window is finite, and the undo control needs a real focus path, not just a mouse target. Keep dialogs for actions that genuinely cannot be undone — permanently destroying data, spending money, anything with a legal or financial consequence — where the interruption is doing real work.
How do I show upload progress in a button?
Drive a single custom property from your progress events and let CSS do the rest: button.style.setProperty('--progress', pct), with the background reading that value through a gradient stop or a scaled pseudo-element. Register the property with @property and a <percentage> syntax so it can transition smoothly between updates instead of stepping between them — an unregistered custom property is just a string to the engine and will not interpolate. If you show a number alongside the bar, set font-variant-numeric: tabular-nums so the digits stay the same width and the label does not jitter on every update. One honesty point worth making: only show a percentage when you actually know the total. If the response is streamed or the size is unknown, a percentage is invented and users notice when it stalls at 90% — an indeterminate spinner is the more truthful control.
How do these port to React, Vue, Svelte and Tailwind?
The CSS ports unchanged — every .lb-NN selector and custom property is framework-agnostic, and the five pure-CSS demos need no JavaScript anywhere. The state machine is where framework idiom matters, and modern React has purpose-built tools for it: useTransition gives you an isPending flag without managing a boolean yourself, and inside a form useFormStatus exposes the parent form's pending state to a nested submit button — which is exactly the case that used to require prop-drilling. Vue reaches for a ref plus onBeforeUnmount; Svelte a plain let and the cleanup returned from onMount. One trap is universal: clear the previous timeout before setting a new one, or a rapid re-click leaves a stale timer that flips the button back to idle while a second request is still running. For Tailwind the states map to data-[state=pending]: variants, which keeps the whole machine in markup.

Related collections

15 CSS 3D Tilt Hover Cards preview

15 CSS 3D Tilt Hover Cards

15 mouse-aware 3D tilt hover cards — e-commerce product spotlight, glassmorphism parallax team card, interactive pricing tier, holographic NFT collectible, pop-out mascot, dark tech grid with border glow, media player album art, blog article preview, cyberpunk neon glow, dashboard KPI widget, minimalist real estate, flip-to-back tilt, mobile app showcase, course learning card, and a Pure CSS touch-friendly tilt. Vanilla JS writes --rx/--ry/--mx/--my; all rendering stays in CSS on the GPU.

24 CSS Animated Cards preview

24 CSS Animated Cards

24 hand-coded CSS animated card patterns organised by what triggers the motion, not by card style. Scroll and entrance triggers: staggered grid reveals, @starting-style mount-in, scroll-driven scale and fade on animation-timeline: view(), View Transitions API card-to-detail morphs, blur-to-sharp lazy image loading, and FLIP re-layout when a filter changes. State-change triggers: add-to-cart success morph, like and save particle burst, expand and collapse to intrinsic height with calc-size(), swipe-to-dismiss on Pointer Events, and skeleton-to-loaded crossfade. Data-driven triggers: count-up KPI metrics, SVG sparkline draw-on-enter, and progress ring fill on load. Ambient idle loops: breathing glow, floating drift, animated gradient mesh backgrounds, conic rotating borders, auto-cycling testimonial decks, and live status pulses. Plus four hover patterns where the effect is the topic itself: cursor spotlight, holographic foil glare, corner ribbon slide, and horizontal accordion expand. Every card is scoped under a .ac-NN prefix for no-collision pasting, guards prefers-reduced-motion, animates compositor-only properties, and ports unchanged to React, Vue, Svelte, Astro, Next.js and Tailwind.

22 CSS Avatars preview

22 CSS Avatars

22 hand-coded CSS avatars for chat apps, team dashboards, comment threads, account menus, and social profiles. Covers circular and squircle shapes, gradient and conic story rings, hexagon clip-path crops, online status dots, notification count badges, verified checkmarks, stacked facepiles with plus-N overflow, initials fallbacks for users without photos, broken-image recovery, avatar pickers, and a size scale system where the ring, badge, and status dot all scale from a single custom property.

Search CodeFronts

Loading…