51 CSS Buttons06 / 51

Light JSMIT licensed

CSS Loading Spinner Button (State Transition)

Idle → pending → success in one button that never changes width, because all three labels are stacked in the same grid cell. The spinner is a conic gradient masked into a ring, the success tick draws itself with stroke-dashoffset, and the state lives in a single data-state attribute mirrored to aria-busy and a live region.

Published

Live Demo
Try it

The code

<div class="cb-06">
  <div class="cb-06__stage">
    <button class="cb-06__btn" type="button" data-state="idle" aria-describedby="cb-06-live">
      <span class="cb-06__slot cb-06__slot--idle">
        <svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.5 10 17.5 19 6.5"/></svg>
        <span>Save changes</span>
      </span>
      <span class="cb-06__slot cb-06__slot--busy">
        <span class="cb-06__spin" aria-hidden="true"></span>
        <span>Saving</span>
      </span>
      <span class="cb-06__slot cb-06__slot--done">
        <svg class="cb-06__tick" viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.5 10 17.5 19 6.5"/></svg>
        <span>Saved</span>
      </span>
    </button>
    <span class="cb-06__live" id="cb-06-live" role="status" aria-live="polite"></span>
  </div>
</div>
@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&display=swap');

.cb-06 {
  --bg: #eef1f5;
  --accent: #1d4ed8;
  --ink: #ffffff;
  --ok: #059669;
  --radius: .8rem;
  --sans: "Outfit",ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;
  width: 100%;
  min-height: 100vh;
  min-height: 100svh;
  display: block;
  background: var(--bg);
  font-family: var(--sans);
  -webkit-font-smoothing: antialiased;
}

@supports (color: oklch(50% 0 0)) {
  .cb-06 {
    --bg: oklch(95.5% .008 250);
    --accent: oklch(48% .22 265);
    --ok: oklch(56% .13 162);
  }
}

@media (prefers-color-scheme: dark) {
  .cb-06:not([data-theme="light"]) {
    --bg: #0a0d12;
    --accent: #3b74f2;
    --ok: #10b981;
  }
}

.cb-06[data-theme="dark"] {
  --bg: #0a0d12;
  --accent: #3b74f2;
  --ok: #10b981;
}

.cb-06,
.cb-06 *,
.cb-06 *::before,
.cb-06 *::after {
  box-sizing: border-box;
}

.cb-06__stage {
  min-height: 100vh;
  min-height: 100svh;
  display: grid;
  place-items: center;
  padding: clamp(1.5rem,6vw,4rem);
}

.cb-06__live {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

.cb-06__btn {
  position: relative;
  cursor: pointer;
  display: grid;
  align-items: center;
  min-height: 3.25rem;
  padding: 0 1.75rem;
  border: 0;
  border-radius: var(--radius);
  font: inherit;
  font-size: 1rem;
  font-weight: 600;
  color: var(--ink);
  background: var(--accent);
  box-shadow: 0 10px 24px -12px color-mix(in oklab,var(--accent) 90%,transparent);
  transition: background-color .3s ease, translate .2s cubic-bezier(.16,1,.3,1), box-shadow .3s ease;
}

.cb-06__slot {
  grid-area: 1/1;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: .55rem;
  opacity: 0;
  scale: .92;
  transition: opacity .22s ease, scale .28s cubic-bezier(.16,1,.3,1);
}

.cb-06__slot svg {
  width: 1.1rem;
  height: 1.1rem;
}

.cb-06__btn[data-state="idle"] .cb-06__slot--idle,
.cb-06__btn[data-state="busy"] .cb-06__slot--busy,
.cb-06__btn[data-state="done"] .cb-06__slot--done {
  opacity: 1;
  scale: 1;
}

.cb-06__btn[data-state="done"] {
  background: var(--ok);
  box-shadow: 0 10px 24px -12px color-mix(in oklab,var(--ok) 90%,transparent);
}

.cb-06__btn[data-state="busy"] {
  cursor: progress;
}

.cb-06__spin {
  width: 1.1rem;
  height: 1.1rem;
  border-radius: 50%;
  flex: none;
  background: conic-gradient(from 0deg,transparent 0 22%,currentColor);
  -webkit-mask: radial-gradient(farthest-side,transparent calc(100% - 2.5px),#000 calc(100% - 2.5px));
  mask: radial-gradient(farthest-side,transparent calc(100% - 2.5px),#000 calc(100% - 2.5px));
  animation: cb-06-spin .7s linear infinite;
}

@keyframes cb-06-spin {
  to {
    rotate: 1turn;
  }
}

.cb-06__tick path {
  stroke-dasharray: 26;
  stroke-dashoffset: 26;
}

.cb-06__btn[data-state="done"] .cb-06__tick path {
  animation: cb-06-draw .45s cubic-bezier(.16,1,.3,1) forwards;
}

@keyframes cb-06-draw {
  to {
    stroke-dashoffset: 0;
  }
}

.cb-06__btn:hover[data-state="idle"] {
  translate: 0 -2px;
  background: color-mix(in oklab,var(--accent) 88%,black);
}

.cb-06__btn:active[data-state="idle"] {
  translate: 0 1px;
}

.cb-06__btn:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 3px;
}

.cb-06__btn:disabled {
  opacity: .9;
}

@media (prefers-reduced-motion: reduce) {
  .cb-06__btn,
    .cb-06__slot {
    transition: none;
  }

  .cb-06__spin {
    animation: cb-06-pulse 1s ease-in-out infinite;
    background: currentColor;
    mask: none;
    -webkit-mask: none;
  }

  @keyframes cb-06-pulse {
    50% {
      opacity: .25;
    }
  }

  .cb-06__tick path {
    stroke-dashoffset: 0;
    animation: none;
  }
}

@media (forced-colors: active) {
  .cb-06__btn {
    background: ButtonFace;
    color: ButtonText;
    border: 1px solid ButtonText;
  }
}
(() => {
  const btn = document.querySelector('.cb-06__btn');
  const live = document.getElementById('cb-06-live');
  if (!btn) return;
  const set = (state, msg) => {
    btn.dataset.state = state;
    btn.disabled = state === 'busy';
    btn.setAttribute('aria-busy', String(state === 'busy'));
    if (msg) live.textContent = msg;
  };
  btn.addEventListener('click', () => {
    if (btn.dataset.state !== 'idle') return;
    set('busy', 'Saving changes');
    // Replace both timeouts with your real await fetch(...) resolution.
    setTimeout(() => set('done', 'Changes saved'), 1500);
    setTimeout(() => set('idle', ''), 3200);
  });
})();
Paste this into ChatGPT, Claude, Cursor, or any coding assistant. The block below is pre-framed with everything the AI needs to integrate this demo into your project — markup, styles, scoping notes, and the source URL. Hit Copy and paste straight into your chat.
Here's a working CSS Button from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: CSS Loading Spinner Button (State Transition)
Source: https://codefronts.com/components/css-buttons/css-loading-spinner-button-state-transition/

Idle → pending → success in one button that never changes width, because all three labels are stacked in the same grid cell. The spinner is a conic gradient masked into a ring, the success tick draws itself with stroke-dashoffset, and the state lives in a single data-state attribute mirrored to aria-busy and a live region.
## HTML
```html
<div class="cb-06">
  <div class="cb-06__stage">
    <button class="cb-06__btn" type="button" data-state="idle" aria-describedby="cb-06-live">
      <span class="cb-06__slot cb-06__slot--idle">
        <svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.5 10 17.5 19 6.5"/></svg>
        <span>Save changes</span>
      </span>
      <span class="cb-06__slot cb-06__slot--busy">
        <span class="cb-06__spin" aria-hidden="true"></span>
        <span>Saving</span>
      </span>
      <span class="cb-06__slot cb-06__slot--done">
        <svg class="cb-06__tick" viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.5 10 17.5 19 6.5"/></svg>
        <span>Saved</span>
      </span>
    </button>
    <span class="cb-06__live" id="cb-06-live" role="status" aria-live="polite"></span>
  </div>
</div>
```
## CSS
```css
@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&display=swap');

.cb-06 {
  --bg: #eef1f5;
  --accent: #1d4ed8;
  --ink: #ffffff;
  --ok: #059669;
  --radius: .8rem;
  --sans: "Outfit",ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;
  width: 100%;
  min-height: 100vh;
  min-height: 100svh;
  display: block;
  background: var(--bg);
  font-family: var(--sans);
  -webkit-font-smoothing: antialiased;
}

@supports (color: oklch(50% 0 0)) {
  .cb-06 {
    --bg: oklch(95.5% .008 250);
    --accent: oklch(48% .22 265);
    --ok: oklch(56% .13 162);
  }
}

@media (prefers-color-scheme: dark) {
  .cb-06:not([data-theme="light"]) {
    --bg: #0a0d12;
    --accent: #3b74f2;
    --ok: #10b981;
  }
}

.cb-06[data-theme="dark"] {
  --bg: #0a0d12;
  --accent: #3b74f2;
  --ok: #10b981;
}

.cb-06,
.cb-06 *,
.cb-06 *::before,
.cb-06 *::after {
  box-sizing: border-box;
}

.cb-06__stage {
  min-height: 100vh;
  min-height: 100svh;
  display: grid;
  place-items: center;
  padding: clamp(1.5rem,6vw,4rem);
}

.cb-06__live {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

.cb-06__btn {
  position: relative;
  cursor: pointer;
  display: grid;
  align-items: center;
  min-height: 3.25rem;
  padding: 0 1.75rem;
  border: 0;
  border-radius: var(--radius);
  font: inherit;
  font-size: 1rem;
  font-weight: 600;
  color: var(--ink);
  background: var(--accent);
  box-shadow: 0 10px 24px -12px color-mix(in oklab,var(--accent) 90%,transparent);
  transition: background-color .3s ease, translate .2s cubic-bezier(.16,1,.3,1), box-shadow .3s ease;
}

.cb-06__slot {
  grid-area: 1/1;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: .55rem;
  opacity: 0;
  scale: .92;
  transition: opacity .22s ease, scale .28s cubic-bezier(.16,1,.3,1);
}

.cb-06__slot svg {
  width: 1.1rem;
  height: 1.1rem;
}

.cb-06__btn[data-state="idle"] .cb-06__slot--idle,
.cb-06__btn[data-state="busy"] .cb-06__slot--busy,
.cb-06__btn[data-state="done"] .cb-06__slot--done {
  opacity: 1;
  scale: 1;
}

.cb-06__btn[data-state="done"] {
  background: var(--ok);
  box-shadow: 0 10px 24px -12px color-mix(in oklab,var(--ok) 90%,transparent);
}

.cb-06__btn[data-state="busy"] {
  cursor: progress;
}

.cb-06__spin {
  width: 1.1rem;
  height: 1.1rem;
  border-radius: 50%;
  flex: none;
  background: conic-gradient(from 0deg,transparent 0 22%,currentColor);
  -webkit-mask: radial-gradient(farthest-side,transparent calc(100% - 2.5px),#000 calc(100% - 2.5px));
  mask: radial-gradient(farthest-side,transparent calc(100% - 2.5px),#000 calc(100% - 2.5px));
  animation: cb-06-spin .7s linear infinite;
}

@keyframes cb-06-spin {
  to {
    rotate: 1turn;
  }
}

.cb-06__tick path {
  stroke-dasharray: 26;
  stroke-dashoffset: 26;
}

.cb-06__btn[data-state="done"] .cb-06__tick path {
  animation: cb-06-draw .45s cubic-bezier(.16,1,.3,1) forwards;
}

@keyframes cb-06-draw {
  to {
    stroke-dashoffset: 0;
  }
}

.cb-06__btn:hover[data-state="idle"] {
  translate: 0 -2px;
  background: color-mix(in oklab,var(--accent) 88%,black);
}

.cb-06__btn:active[data-state="idle"] {
  translate: 0 1px;
}

.cb-06__btn:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 3px;
}

.cb-06__btn:disabled {
  opacity: .9;
}

@media (prefers-reduced-motion: reduce) {
  .cb-06__btn,
    .cb-06__slot {
    transition: none;
  }

  .cb-06__spin {
    animation: cb-06-pulse 1s ease-in-out infinite;
    background: currentColor;
    mask: none;
    -webkit-mask: none;
  }

  @keyframes cb-06-pulse {
    50% {
      opacity: .25;
    }
  }

  .cb-06__tick path {
    stroke-dashoffset: 0;
    animation: none;
  }
}

@media (forced-colors: active) {
  .cb-06__btn {
    background: ButtonFace;
    color: ButtonText;
    border: 1px solid ButtonText;
  }
}
```

## JavaScript
```js
(() => {
  const btn = document.querySelector('.cb-06__btn');
  const live = document.getElementById('cb-06-live');
  if (!btn) return;
  const set = (state, msg) => {
    btn.dataset.state = state;
    btn.disabled = state === 'busy';
    btn.setAttribute('aria-busy', String(state === 'busy'));
    if (msg) live.textContent = msg;
  };
  btn.addEventListener('click', () => {
    if (btn.dataset.state !== 'idle') return;
    set('busy', 'Saving changes');
    // Replace both timeouts with your real await fetch(...) resolution.
    setTimeout(() => set('done', 'Changes saved'), 1500);
    setTimeout(() => set('idle', ''), 3200);
  });
})();
```

How this works

Layout stability comes from stacking every state on grid-area:1/1 so the button is always as wide as its widest label — no jump when "Save changes" becomes "Saving":

.cb-06__btn{ display:grid; }
.cb-06__slot{ grid-area:1/1; display:flex; gap:.5rem; justify-content:center;
  opacity:0; scale:.9; transition:opacity .22s, scale .22s; }
.cb-06__btn[data-state='idle'] .cb-06__slot--idle,
.cb-06__btn[data-state='busy'] .cb-06__slot--busy,
.cb-06__btn[data-state='done'] .cb-06__slot--done{ opacity:1; scale:1; }

The spinner needs no SVG: a conic gradient clipped to a ring by two masks, spun with one keyframe.

.cb-06__spin{
  background:conic-gradient(from 0deg,transparent 0 25%,currentColor);
  mask:radial-gradient(farthest-side,transparent calc(100% - 2.5px),#000 0);
  animation:cb-06-spin .7s linear infinite;
}

The JS is nine lines: set data-state, toggle disabled and aria-busy, write "Saving" / "Saved" into an aria-live="polite" span so the change is announced, then return to idle. Announcement is text, never the spinner — motion alone is invisible to assistive tech.

Make it yours

  • Wire it to a real request: replace the setTimeout with your await fetch() and set data-state from the resolved/rejected branch.
  • Add an error state by copying the done slot, swapping the icon for an alert glyph and tinting with --danger.
  • Hold the success state longer by raising the second timeout; 1200-1600ms is the readable range.
  • Make the spinner thicker by changing the 2.5px inside both mask stops (they must match).
  • For destructive actions keep the button disabled after success and let the parent view navigate instead of resetting.

Gotchas — read before shipping

  • Disabled buttons are removed from the tab order, so focus is lost mid-flow. Either keep focus by not disabling and using aria-disabled, or accept it and move focus deliberately after the response.
  • A spinner with no text change is silent to screen readers. aria-busy="true" plus a polite live region is the minimum.
  • Never animate width between states — stack the labels. Width animation causes reflow on every frame and text reflow mid-transition.
  • mask still needs -webkit-mask for older WebKit; both are declared here.
  • Under prefers-reduced-motion a spinning ring should become a pulsing or static indicator — the state must remain visible, so don't just delete the animation without a replacement (this demo swaps to an opacity pulse).

Browser support

ChromeSafariFirefoxEdge
120+15.4+113+120+

mask shorthand needs Chrome 120 / Safari 15.4 / Firefox 53 (with -webkit- prefix declared first for Safari ≤ 15.3). conic-gradient, grid stacking and data-attribute selectors are universal.

Techniques used in this demo

Search CodeFronts

Loading…