34 CSS Floating Buttons
29 / 34

All 34 in 34 CSS Floating Buttons

  1. 01Material Design 3 Floating Action Button (FAB)
  2. 02iOS-Style Compose Floating Button
  3. 03WhatsApp Floating Message Button CSS
  4. 04Intercom / Drift Chat Bubble Floating Button
  5. 05Accessible WAI-ARIA Floating Action Button (WCAG 2.2)
  6. 06Extended FAB with Icon and Label (Material Design 3)
  7. 07Speed Dial Radial Menu FAB with Backdrop
  8. 08Pure CSS FAB Speed Dial (No JavaScript)
  9. 09Material Gradient Floating Action Button CSS
  10. 10Vertical and Horizontal Speed Dial FAB Menu CSS
  11. 11Morphing FAB Button That Expands to Pill CSS
  12. 12Scroll-to-Top Floating Button with SVG Progress Ring
  13. 13Floating Chat Widget FAB with Notification Panel
  14. 14Fan and Arc Radial Spread FAB Menu CSS
  15. 15Apple Squircle Superellipse Floating Button CSS
  16. 16Social Share Floating Button Cluster CSS
  17. 17Glassmorphism Floating Action Button with Backdrop Blur
  18. 18Cyberpunk Neon Glow Floating Button CSS
  19. 19Floating Compose Button with Tooltip on Hover CSS
  20. 20Draggable Floating Action Button with Corner Snap CSS
  21. 21Notification Bell Floating Button with Badge Counter
  22. 22Floating Cart Button with Live Badge Counter CSS
  23. 23Magnetic Cursor Follower Floating Button CSS
  24. 24Dark Mode Theme Toggle Floating Button CSS
  25. 25Classic Gradient Circle Plus FAB CSS
  26. 26Pulse Ring Attention-Getter Floating Button CSS
  27. 27Pill-Shaped FAB with Icon and Text Label CSS
  28. 28Neo-Brutalist Hard Shadow Floating Button CSS
  29. 29Floating Button with Loading Spinner State CSS
  30. 30Draggable Floating Button with 6-Dot Grip Handle CSS
  31. 31Voice Recording Microphone Floating Button CSS
  32. 32Aurora Gradient Drift Premium Floating Button CSS
  33. 33Quick Emoji Reply Reaction Floating Button CSS
  34. 34Modern Squircle Rounded Square FAB CSS
Light JSMIT licensed

Floating Button with Loading Spinner State CSS

The three-state async button — idle, pending, done — with the transitions between them treated as seriously as the states themselves. The spinner uses an animated dash gap so it never appears to stall, the success check draws in with stroke-dashoffset, and the whole thing is announced through aria-busy and a status region rather than by colour alone.

Live Demo
Try it

Nucleo: 44,000+ SVG icons in five families · All Icons Bundle $149

Nucleo · Affiliate link

Get the Nucleo bundle

The code

<section class="fb-29" aria-label="Floating button with loading state demo">
  <div class="fb-29__stage">
    <p class="fb-29__eyebrow">idle → loading → done → idle</p>
    <h2 class="fb-29__title">Three states, one attribute</h2>
    <p class="fb-29__sub">Click any button. The dash gap breathes so the spinner never looks frozen, the check draws in over 420ms, and it all resets after a beat.</p>
    <div class="fb-29__row">
      <button class="fb-29__fab" id="fb-29-a" type="button" data-state="idle" aria-label="Save document">
        <span class="fb-29__ico" aria-hidden="true"><svg viewBox="0 0 24 24" focusable="false"><path d="M5 4h11l3 3v13H5z" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linejoin="round"/><path d="M8 4v5h7M8 20v-6h8v6" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linejoin="round"/></svg></span>
        <span class="fb-29__spin" aria-hidden="true"><svg viewBox="0 0 44 44" focusable="false"><circle cx="22" cy="22" r="17" fill="none" stroke="currentColor" stroke-width="3.4" stroke-linecap="round"/></svg></span>
        <span class="fb-29__ok" aria-hidden="true"><svg viewBox="0 0 24 24" focusable="false"><path class="fb-29__check" d="M5 12.6l4.6 4.6L19 7.4" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/></svg></span>
      </button>
      <button class="fb-29__pill" id="fb-29-b" type="button" data-state="idle">
        <span class="fb-29__iconstack" aria-hidden="true">
          <span class="fb-29__ico"><svg viewBox="0 0 24 24" focusable="false"><path d="M12 17V6M8 10l4-4 4 4M5 19h14" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"/></svg></span>
          <span class="fb-29__spin"><svg viewBox="0 0 44 44" focusable="false"><circle cx="22" cy="22" r="17" fill="none" stroke="currentColor" stroke-width="3.4" stroke-linecap="round"/></svg></span>
          <span class="fb-29__ok"><svg viewBox="0 0 24 24" focusable="false"><path class="fb-29__check" d="M5 12.6l4.6 4.6L19 7.4" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/></svg></span>
        </span>
        <b class="fb-29__label" id="fb-29-blabel">Publish</b>
      </button>
    </div>
    <p class="fb-29__live" id="fb-29-live" role="status" aria-live="polite">Ready</p>
    <p class="fb-29__chip">aria-busy · 400ms spinner floor · auto-reset 1.8s</p>
  </div>
</section>
.fb-29 {
  width: 100%;
  min-height: 100vh;
  min-height: 100svh;
  display: block;
  background: var(--fb-29-bg);
  --fb-29-bg: linear-gradient(180deg,oklch(0.98 0.008 265),oklch(0.94 0.02 258));
  --fb-29-brand: oklch(0.52 0.17 265);
  --fb-29-brandDeep: oklch(0.38 0.15 268);
  --fb-29-ok: oklch(0.62 0.16 148);
  --fb-29-ink: oklch(0.22 0.025 265);
  --fb-29-mut: oklch(0.53 0.015 265);
  font-family: 'Segoe UI',system-ui,-apple-system,sans-serif;
  color: var(--fb-29-ink);
  display: grid;
  place-items: center;
  padding: clamp(20px,5vw,56px);
}

.fb-29 *,
.fb-29 *::before,
.fb-29 *::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

.fb-29__stage {
  width: min(100%,56ch);
  display: grid;
  gap: clamp(16px,3vw,26px);
  justify-items: center;
  text-align: center;
}

.fb-29__eyebrow {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: 11.5px;
  letter-spacing: .16em;
  text-transform: uppercase;
  color: var(--fb-29-brand);
}

.fb-29__title {
  font-size: clamp(26px,4.6vw,44px);
  line-height: 1.05;
  letter-spacing: -.03em;
  font-weight: 700;
  text-wrap: balance;
}

.fb-29__sub {
  max-width: 50ch;
  font-size: clamp(13.5px,1.7vw,16.5px);
  line-height: 1.62;
  color: var(--fb-29-mut);
  text-wrap: pretty;
}

.fb-29__row {
  display: flex;
  flex-wrap: wrap;
  gap: 22px;
  justify-content: center;
  align-items: center;
  padding: 8px 0;
}

.fb-29__fab,
.fb-29__pill {
  position: relative;
  border: 0;
  border-radius: 22px;
  cursor: pointer;
  color: oklch(0.99 0.01 265);
  background: linear-gradient(150deg,oklch(0.6 0.18 262),var(--fb-29-brandDeep));
  box-shadow: 0 14px 32px -12px oklch(0.38 0.15 268/.85),0 2px 6px oklch(0.3 0.07 268/.28);
  transition: transform .26s cubic-bezier(.2,1.3,.4,1),background .3s ease,box-shadow .3s ease;
}

.fb-29__fab {
  display: grid;
  place-items: center;
  inline-size: 64px;
  block-size: 64px;
}

.fb-29__pill {
  display: inline-flex;
  align-items: center;
  gap: 11px;
  block-size: 64px;
  padding-inline: 22px 26px;
  border-radius: 99px;
  font-family: inherit;
  font-size: 16px;
  font-weight: 640;
}

.fb-29__pill .fb-29__iconstack {
  position: relative;
  display: grid;
  place-items: center;
  inline-size: 30px;
  block-size: 30px;
  flex: none;
}

.fb-29__fab:hover,
.fb-29__pill:hover {
  transform: translateY(-2px);
}

.fb-29__fab:active,
.fb-29__pill:active {
  transform: scale(.96);
}

.fb-29__fab:focus-visible,
.fb-29__pill:focus-visible {
  outline: 3px solid var(--fb-29-brandDeep);
  outline-offset: 4px;
}

.fb-29__fab>.fb-29__ico,
.fb-29__fab>.fb-29__spin,
.fb-29__fab>.fb-29__ok,
.fb-29__pill .fb-29__iconstack>.fb-29__ico,
.fb-29__pill .fb-29__iconstack>.fb-29__spin,
.fb-29__pill .fb-29__iconstack>.fb-29__ok {
  grid-area: 1/1;
  display: grid;
  place-items: center;
  transition: opacity .2s ease,scale .28s cubic-bezier(.2,1.4,.4,1);
}

.fb-29__ico svg,
.fb-29__ok svg {
  width: 26px;
  height: 26px;
}

.fb-29__spin svg {
  width: 30px;
  height: 30px;
  animation: fb-29-rot 1.1s linear infinite;
}

.fb-29__spin circle {
  stroke-dasharray: 22 90;
  animation: fb-29-dash 1.5s ease-in-out infinite;
}

@keyframes fb-29-rot {
  to {
    rotate: 360deg;
  }
}

@keyframes fb-29-dash {
  0% {
    stroke-dasharray: 12 96;
    stroke-dashoffset: 0;
  }

  50% {
    stroke-dasharray: 62 46;
    stroke-dashoffset: -16;
  }

  100% {
    stroke-dasharray: 12 96;
    stroke-dashoffset: -104;
  }
}

.fb-29__spin,
.fb-29__ok {
  opacity: 0;
  scale: .6;
  pointer-events: none;
}

.fb-29__check {
  stroke-dasharray: var(--fb-29-len,32);
  stroke-dashoffset: var(--fb-29-len,32);
}

[data-state="loading"] .fb-29__ico {
  opacity: 0;
  scale: .6;
}

[data-state="loading"] .fb-29__spin {
  opacity: 1;
  scale: 1;
}

[data-state="done"] .fb-29__ico {
  opacity: 0;
  scale: .6;
}

[data-state="done"] .fb-29__ok {
  opacity: 1;
  scale: 1;
}

[data-state="done"] .fb-29__check {
  animation: fb-29-draw .42s cubic-bezier(.2,.9,.3,1) forwards;
}

@keyframes fb-29-draw {
  to {
    stroke-dashoffset: 0;
  }
}

.fb-29__fab[data-state="done"],
.fb-29__pill[data-state="done"] {
  background: linear-gradient(150deg,oklch(0.7 0.16 150),oklch(0.5 0.14 150));
  box-shadow: 0 14px 32px -12px oklch(0.5 0.14 150/.8);
}

.fb-29__fab[data-state="loading"],
.fb-29__pill[data-state="loading"] {
  cursor: progress;
}

.fb-29__label {
  font-weight: 640;
  letter-spacing: -.01em;
}

.fb-29__live {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: 12px;
  color: var(--fb-29-brandDeep);
  background: oklch(0.52 0.17 265/.09);
  border-radius: 10px;
  padding: 8px 15px;
  min-width: 12ch;
}

.fb-29__chip {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: 11px;
  color: var(--fb-29-mut);
  padding: 8px 14px;
  border: 1px solid oklch(0.22 0.025 265/.14);
  border-radius: 99px;
}

@media (prefers-reduced-motion: reduce) {
  .fb-29 * {
    animation-duration: .01ms !important;
    transition: none !important;
  }

  .fb-29__check {
    stroke-dashoffset: 0;
  }
}
(() => {
  const live = document.getElementById('fb-29-live');
  const label = document.getElementById('fb-29-blabel');
  const buttons = [document.getElementById('fb-29-a'), document.getElementById('fb-29-b')].filter(Boolean);
  if (!buttons.length || buttons[0].dataset.fbWired) return;
  buttons[0].dataset.fbWired = '1';
  document.querySelectorAll('.fb-29__check').forEach((p) => {
    const len = Math.ceil(p.getTotalLength() || 32);
    p.style.setProperty('--fb-29-len', len);
  });
  buttons.forEach((btn) => {
    btn.addEventListener('click', () => {
      if (btn.dataset.state !== 'idle') return;
      btn.dataset.state = 'loading';
      btn.setAttribute('aria-busy', 'true');
      btn.disabled = true;
      if (label && btn.contains(label)) label.textContent = 'Publishing…';
      live.textContent = 'Working…';
      setTimeout(() => {
        btn.dataset.state = 'done';
        btn.setAttribute('aria-busy', 'false');
        if (label && btn.contains(label)) label.textContent = 'Published';
        live.textContent = 'Done — saved just now';
        setTimeout(() => {
          btn.dataset.state = 'idle';
          btn.disabled = false;
          if (label && btn.contains(label)) label.textContent = 'Publish';
          live.textContent = 'Ready';
        }, 1800);
      }, 1500);
    });
  });
})();
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 Floating 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: Floating Button with Loading Spinner State CSS
Source: https://codefronts.com/components/css-floating-buttons/fb11/

The three-state async button — idle, pending, done — with the transitions between them treated as seriously as the states themselves. The spinner uses an animated dash gap so it never appears to stall, the success check draws in with stroke-dashoffset, and the whole thing is announced through aria-busy and a status region rather than by colour alone.
## HTML
```html
<section class="fb-29" aria-label="Floating button with loading state demo">
  <div class="fb-29__stage">
    <p class="fb-29__eyebrow">idle → loading → done → idle</p>
    <h2 class="fb-29__title">Three states, one attribute</h2>
    <p class="fb-29__sub">Click any button. The dash gap breathes so the spinner never looks frozen, the check draws in over 420ms, and it all resets after a beat.</p>
    <div class="fb-29__row">
      <button class="fb-29__fab" id="fb-29-a" type="button" data-state="idle" aria-label="Save document">
        <span class="fb-29__ico" aria-hidden="true"><svg viewBox="0 0 24 24" focusable="false"><path d="M5 4h11l3 3v13H5z" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linejoin="round"/><path d="M8 4v5h7M8 20v-6h8v6" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linejoin="round"/></svg></span>
        <span class="fb-29__spin" aria-hidden="true"><svg viewBox="0 0 44 44" focusable="false"><circle cx="22" cy="22" r="17" fill="none" stroke="currentColor" stroke-width="3.4" stroke-linecap="round"/></svg></span>
        <span class="fb-29__ok" aria-hidden="true"><svg viewBox="0 0 24 24" focusable="false"><path class="fb-29__check" d="M5 12.6l4.6 4.6L19 7.4" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/></svg></span>
      </button>
      <button class="fb-29__pill" id="fb-29-b" type="button" data-state="idle">
        <span class="fb-29__iconstack" aria-hidden="true">
          <span class="fb-29__ico"><svg viewBox="0 0 24 24" focusable="false"><path d="M12 17V6M8 10l4-4 4 4M5 19h14" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"/></svg></span>
          <span class="fb-29__spin"><svg viewBox="0 0 44 44" focusable="false"><circle cx="22" cy="22" r="17" fill="none" stroke="currentColor" stroke-width="3.4" stroke-linecap="round"/></svg></span>
          <span class="fb-29__ok"><svg viewBox="0 0 24 24" focusable="false"><path class="fb-29__check" d="M5 12.6l4.6 4.6L19 7.4" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/></svg></span>
        </span>
        <b class="fb-29__label" id="fb-29-blabel">Publish</b>
      </button>
    </div>
    <p class="fb-29__live" id="fb-29-live" role="status" aria-live="polite">Ready</p>
    <p class="fb-29__chip">aria-busy · 400ms spinner floor · auto-reset 1.8s</p>
  </div>
</section>
```
## CSS
```css
.fb-29 {
  width: 100%;
  min-height: 100vh;
  min-height: 100svh;
  display: block;
  background: var(--fb-29-bg);
  --fb-29-bg: linear-gradient(180deg,oklch(0.98 0.008 265),oklch(0.94 0.02 258));
  --fb-29-brand: oklch(0.52 0.17 265);
  --fb-29-brandDeep: oklch(0.38 0.15 268);
  --fb-29-ok: oklch(0.62 0.16 148);
  --fb-29-ink: oklch(0.22 0.025 265);
  --fb-29-mut: oklch(0.53 0.015 265);
  font-family: 'Segoe UI',system-ui,-apple-system,sans-serif;
  color: var(--fb-29-ink);
  display: grid;
  place-items: center;
  padding: clamp(20px,5vw,56px);
}

.fb-29 *,
.fb-29 *::before,
.fb-29 *::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

.fb-29__stage {
  width: min(100%,56ch);
  display: grid;
  gap: clamp(16px,3vw,26px);
  justify-items: center;
  text-align: center;
}

.fb-29__eyebrow {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: 11.5px;
  letter-spacing: .16em;
  text-transform: uppercase;
  color: var(--fb-29-brand);
}

.fb-29__title {
  font-size: clamp(26px,4.6vw,44px);
  line-height: 1.05;
  letter-spacing: -.03em;
  font-weight: 700;
  text-wrap: balance;
}

.fb-29__sub {
  max-width: 50ch;
  font-size: clamp(13.5px,1.7vw,16.5px);
  line-height: 1.62;
  color: var(--fb-29-mut);
  text-wrap: pretty;
}

.fb-29__row {
  display: flex;
  flex-wrap: wrap;
  gap: 22px;
  justify-content: center;
  align-items: center;
  padding: 8px 0;
}

.fb-29__fab,
.fb-29__pill {
  position: relative;
  border: 0;
  border-radius: 22px;
  cursor: pointer;
  color: oklch(0.99 0.01 265);
  background: linear-gradient(150deg,oklch(0.6 0.18 262),var(--fb-29-brandDeep));
  box-shadow: 0 14px 32px -12px oklch(0.38 0.15 268/.85),0 2px 6px oklch(0.3 0.07 268/.28);
  transition: transform .26s cubic-bezier(.2,1.3,.4,1),background .3s ease,box-shadow .3s ease;
}

.fb-29__fab {
  display: grid;
  place-items: center;
  inline-size: 64px;
  block-size: 64px;
}

.fb-29__pill {
  display: inline-flex;
  align-items: center;
  gap: 11px;
  block-size: 64px;
  padding-inline: 22px 26px;
  border-radius: 99px;
  font-family: inherit;
  font-size: 16px;
  font-weight: 640;
}

.fb-29__pill .fb-29__iconstack {
  position: relative;
  display: grid;
  place-items: center;
  inline-size: 30px;
  block-size: 30px;
  flex: none;
}

.fb-29__fab:hover,
.fb-29__pill:hover {
  transform: translateY(-2px);
}

.fb-29__fab:active,
.fb-29__pill:active {
  transform: scale(.96);
}

.fb-29__fab:focus-visible,
.fb-29__pill:focus-visible {
  outline: 3px solid var(--fb-29-brandDeep);
  outline-offset: 4px;
}

.fb-29__fab>.fb-29__ico,
.fb-29__fab>.fb-29__spin,
.fb-29__fab>.fb-29__ok,
.fb-29__pill .fb-29__iconstack>.fb-29__ico,
.fb-29__pill .fb-29__iconstack>.fb-29__spin,
.fb-29__pill .fb-29__iconstack>.fb-29__ok {
  grid-area: 1/1;
  display: grid;
  place-items: center;
  transition: opacity .2s ease,scale .28s cubic-bezier(.2,1.4,.4,1);
}

.fb-29__ico svg,
.fb-29__ok svg {
  width: 26px;
  height: 26px;
}

.fb-29__spin svg {
  width: 30px;
  height: 30px;
  animation: fb-29-rot 1.1s linear infinite;
}

.fb-29__spin circle {
  stroke-dasharray: 22 90;
  animation: fb-29-dash 1.5s ease-in-out infinite;
}

@keyframes fb-29-rot {
  to {
    rotate: 360deg;
  }
}

@keyframes fb-29-dash {
  0% {
    stroke-dasharray: 12 96;
    stroke-dashoffset: 0;
  }

  50% {
    stroke-dasharray: 62 46;
    stroke-dashoffset: -16;
  }

  100% {
    stroke-dasharray: 12 96;
    stroke-dashoffset: -104;
  }
}

.fb-29__spin,
.fb-29__ok {
  opacity: 0;
  scale: .6;
  pointer-events: none;
}

.fb-29__check {
  stroke-dasharray: var(--fb-29-len,32);
  stroke-dashoffset: var(--fb-29-len,32);
}

[data-state="loading"] .fb-29__ico {
  opacity: 0;
  scale: .6;
}

[data-state="loading"] .fb-29__spin {
  opacity: 1;
  scale: 1;
}

[data-state="done"] .fb-29__ico {
  opacity: 0;
  scale: .6;
}

[data-state="done"] .fb-29__ok {
  opacity: 1;
  scale: 1;
}

[data-state="done"] .fb-29__check {
  animation: fb-29-draw .42s cubic-bezier(.2,.9,.3,1) forwards;
}

@keyframes fb-29-draw {
  to {
    stroke-dashoffset: 0;
  }
}

.fb-29__fab[data-state="done"],
.fb-29__pill[data-state="done"] {
  background: linear-gradient(150deg,oklch(0.7 0.16 150),oklch(0.5 0.14 150));
  box-shadow: 0 14px 32px -12px oklch(0.5 0.14 150/.8);
}

.fb-29__fab[data-state="loading"],
.fb-29__pill[data-state="loading"] {
  cursor: progress;
}

.fb-29__label {
  font-weight: 640;
  letter-spacing: -.01em;
}

.fb-29__live {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: 12px;
  color: var(--fb-29-brandDeep);
  background: oklch(0.52 0.17 265/.09);
  border-radius: 10px;
  padding: 8px 15px;
  min-width: 12ch;
}

.fb-29__chip {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: 11px;
  color: var(--fb-29-mut);
  padding: 8px 14px;
  border: 1px solid oklch(0.22 0.025 265/.14);
  border-radius: 99px;
}

@media (prefers-reduced-motion: reduce) {
  .fb-29 * {
    animation-duration: .01ms !important;
    transition: none !important;
  }

  .fb-29__check {
    stroke-dashoffset: 0;
  }
}
```

## JavaScript
```js
(() => {
  const live = document.getElementById('fb-29-live');
  const label = document.getElementById('fb-29-blabel');
  const buttons = [document.getElementById('fb-29-a'), document.getElementById('fb-29-b')].filter(Boolean);
  if (!buttons.length || buttons[0].dataset.fbWired) return;
  buttons[0].dataset.fbWired = '1';
  document.querySelectorAll('.fb-29__check').forEach((p) => {
    const len = Math.ceil(p.getTotalLength() || 32);
    p.style.setProperty('--fb-29-len', len);
  });
  buttons.forEach((btn) => {
    btn.addEventListener('click', () => {
      if (btn.dataset.state !== 'idle') return;
      btn.dataset.state = 'loading';
      btn.setAttribute('aria-busy', 'true');
      btn.disabled = true;
      if (label && btn.contains(label)) label.textContent = 'Publishing…';
      live.textContent = 'Working…';
      setTimeout(() => {
        btn.dataset.state = 'done';
        btn.setAttribute('aria-busy', 'false');
        if (label && btn.contains(label)) label.textContent = 'Published';
        live.textContent = 'Done — saved just now';
        setTimeout(() => {
          btn.dataset.state = 'idle';
          btn.disabled = false;
          if (label && btn.contains(label)) label.textContent = 'Publish';
          live.textContent = 'Ready';
        }, 1800);
      }, 1500);
    });
  });
})();
```

How this works

A single data-state attribute drives the CSS; the JS only ever changes that one value:

[data-state="loading"] .icon{ opacity:0; scale:.6 }
[data-state="loading"] .spinner{ opacity:1 }
[data-state="done"] .check{ animation:draw .42s cubic-bezier(.2,.9,.3,1) forwards }
@keyframes draw{ to{ stroke-dashoffset:0 } }

The spinner is an SVG circle with stroke-dasharray animated between two lengths while the whole element rotates. A fixed-length arc spinning at constant speed reads as frozen when it happens to sync with your eye; a breathing dash gap never does.

The check mark is one path with its stroke-dasharray and stroke-dashoffset both set to the path length, animated to zero — the standard draw-on technique, and the most convincing "complete" cue there is, because it takes time the way a real confirmation does.

Accessibility: the button gets aria-busy="true" and disabled while pending (so a double-click cannot fire twice), a role="status" region announces "Saving" and then "Saved", and after 1.8s it returns to idle. Never leave a button permanently in a success state — the user's next action needs the idle affordance back.

Make it yours

  • Progress instead of spin: if you know the percentage, animate stroke-dashoffset from the real value — a determinate ring always beats a spinner.
  • Error state: a fourth data-state with a shake (three 4px translations) and an X draw, returning to idle after 2.5s.
  • Extended pill: the same three states work on a labelled pill where the text swaps Save → Saving… → Saved.
  • Optimistic UI: skip the pending state for actions under ~300ms and go straight to done — a spinner that flashes is worse than none.

Gotchas — read before shipping

  • Disabling the button without aria-busy tells assistive tech the control is unavailable, not that it is working. Use both.
  • A spinner shown for under 200ms flashes and reads as a glitch. Either delay showing it, or hold it for a 400ms minimum once shown.
  • Do not change the button's size between states; a shrinking button moves the pointer target mid-interaction.
  • Success states that persist indefinitely block the next action. Always time out back to idle.

Browser support

ChromeSafariFirefoxEdge
114+17.5+121+114+

Floor set by oklch(), text-wrap as this demo is written. The core technique itself goes back further — Chrome 111+.

SVG stroke animation and keyframes work in every browser. getTotalLength() is used to measure the check path and is supported since IE9.

Search CodeFronts

Loading…