20 CSS Loading Buttons06 / 20

Light JSMIT licensed

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

Live Demo
Try it

The code

<div class="lb-06">
  <div class="lb-06__stage">
    <section class="lb-06__card" aria-labelledby="lb-06-h">
      <h2 class="lb-06__h" id="lb-06-h">Weekly digest</h2>
      <p class="lb-06__sub">Sent Fridays. Written by Jordan Lee. One click to confirm — the spinner becomes the tick.</p>

      <div class="lb-06__form">
        <span class="lb-06__input" aria-hidden="true">jordan@fieldnotes.dev</span>
        <button class="lb-06__btn" type="button" id="lb-06-btn" data-state="idle" aria-busy="false">
          <span class="lb-06__mark" aria-hidden="true">
            <svg viewBox="0 0 20 20" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round">
              <circle class="lb-06__ring" cx="10" cy="10" r="7.4"/>
              <path class="lb-06__check" d="M5.4 10.4 8.6 13.6 14.8 6.8"/>
              <path class="lb-06__cross" d="M6.4 6.4l7.2 7.2M13.6 6.4l-7.2 7.2"/>
            </svg>
          </span>
          <span class="lb-06__faces">
            <span class="lb-06__face lb-06__face--idle">Subscribe</span>
            <span class="lb-06__face lb-06__face--pending">Confirming</span>
            <span class="lb-06__face lb-06__face--done">Subscribed</span>
            <span class="lb-06__face lb-06__face--error">Try again</span>
          </span>
        </button>
      </div>

      <label class="lb-06__toggle"><input type="checkbox" id="lb-06-fail"> Make the next attempt fail</label>
      <p class="lb-06__live" id="lb-06-live" role="status" aria-live="polite">Not subscribed yet.</p>
    </section>
  </div>
</div>
.lb-06 {
  width: 100%;
  min-height: 100vh;
  display: block;
  box-sizing: border-box;
  --bg: #eef2ec;
  --surface: #ffffff;
  --ink: #1d2a22;
  --muted: #63776a;
  --rule: #d5e0d6;
  --accent: #2f6b4f;
  --mark: #ffffff;
  --bad: #a63b2c;
  --draw: 260ms;
  --hold: 1400ms;
  font-family: ui-sans-serif, system-ui, "Segoe UI", sans-serif;
  background: var(--bg);
  color: var(--ink);
  padding: 40px 18px;
}

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

.lb-06__stage {
  display: grid;
  place-items: start center;
  max-inline-size: 100%;
  min-height: calc(100vh - 80px);
}

.lb-06__card {
  inline-size: 100%;
  max-inline-size: 32rem;
  min-inline-size: 0;
  background: var(--surface);
  border-radius: 20px;
  padding: 26px 24px;
  box-shadow: 0 20px 40px -28px rgba(29, 42, 34, .55), 0 1px 0 var(--rule);
}

.lb-06__h {
  margin: 0;
  font-size: 21px;
  font-weight: 620;
  letter-spacing: -.02em;
}

.lb-06__sub {
  margin: 8px 0 20px;
  font-size: 13px;
  line-height: 1.6;
  color: var(--muted);
  max-inline-size: 46ch;
}

.lb-06__form {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
  align-items: center;
  min-inline-size: 0;
}

.lb-06__input {
  flex: 1 1 12rem;
  min-inline-size: 0;
  font-size: 13px;
  color: var(--muted);
  background: var(--bg);
  border: 1px solid var(--rule);
  border-radius: 999px;
  padding: 12px 16px;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.lb-06__btn {
  appearance: none;
  flex: none;
  border: 0;
  background: var(--accent);
  color: var(--mark);
  border-radius: 999px;
  font: inherit;
  font-size: 13.5px;
  font-weight: 600;
  padding: 0 20px 0 16px;
  min-block-size: 44px;
  min-inline-size: 0;
  display: inline-flex;
  align-items: center;
  gap: 9px;
  cursor: pointer;
  transition: background 240ms ease;
}

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

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

.lb-06__btn[data-state="error"] {
  background: var(--bad);
}

.lb-06__mark {
  display: inline-flex;
  flex: none;
}

.lb-06__ring,
.lb-06__check,
.lb-06__cross {
  transition: stroke-dashoffset var(--draw) ease, opacity 160ms linear;
}

.lb-06__ring {
  stroke-dasharray: 12 46;
  stroke-dashoffset: 0;
  transform-box: fill-box;
  transform-origin: 50% 50%;
}

.lb-06__check {
  stroke-dasharray: 24;
  stroke-dashoffset: 24;
}

.lb-06__cross {
  stroke-dasharray: 22;
  stroke-dashoffset: 22;
}

.lb-06__btn[data-state="idle"] .lb-06__ring {
  stroke-dasharray: 47 0;
}

.lb-06__btn[data-state="pending"] .lb-06__ring {
  animation: lb-06-spin 700ms linear infinite;
}

.lb-06__btn[data-state="done"] .lb-06__ring {
  opacity: 0;
}

.lb-06__btn[data-state="done"] .lb-06__check {
  stroke-dashoffset: 0;
}

.lb-06__btn[data-state="error"] .lb-06__ring {
  opacity: 0;
}

.lb-06__btn[data-state="error"] .lb-06__cross {
  stroke-dashoffset: 0;
}

@keyframes lb-06-spin {
  to {
    transform: rotate(1turn);
  }
}

.lb-06__faces {
  display: grid;
  place-items: center;
}

.lb-06__face {
  grid-area: 1 / 1;
  white-space: nowrap;
  opacity: 0;
  visibility: hidden;
  transition: opacity 180ms ease, visibility 180ms;
}

.lb-06__btn[data-state="idle"] .lb-06__face--idle,
.lb-06__btn[data-state="pending"] .lb-06__face--pending,
.lb-06__btn[data-state="done"] .lb-06__face--done,
.lb-06__btn[data-state="error"] .lb-06__face--error {
  opacity: 1;
  visibility: visible;
}

.lb-06__toggle {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  margin: 18px 0 0;
  font-size: 12px;
  color: var(--muted);
  min-height: 44px;
}

.lb-06__toggle input {
  accent-color: var(--accent);
  inline-size: 15px;
  block-size: 15px;
}

.lb-06__live {
  margin: 4px 0 0;
  font-size: 12.5px;
  color: var(--muted);
  min-inline-size: 0;
}

@media (prefers-reduced-motion: reduce) {
  .lb-06__ring,
    .lb-06__check,
    .lb-06__cross {
    transition: none;
  }

  .lb-06__btn[data-state="pending"] .lb-06__ring {
    animation: none;
    stroke-dasharray: 24 24;
  }
}

@media (prefers-color-scheme: dark) {
  .lb-06 {
    --bg: #10160f;
    --surface: #18211a;
    --ink: #e7f0e6;
    --muted: #94a798;
    --rule: #263226;
    --accent: #4fa87a;
    --mark: #0e150f;
    --bad: #e07a68;
  }
}

[data-theme="dark"] .lb-06 {
  --bg: #10160f;
  --surface: #18211a;
  --ink: #e7f0e6;
  --muted: #94a798;
  --rule: #263226;
  --accent: #4fa87a;
  --mark: #0e150f;
  --bad: #e07a68;
}
const btn = document.getElementById('lb-06-btn');
const live = document.getElementById('lb-06-live');
const failBox = document.getElementById('lb-06-fail');
let timer;

const set = (state, message) => {
  btn.dataset.state = state;
  btn.setAttribute('aria-busy', String(state === 'pending'));
  live.textContent = message;
};

btn.addEventListener('click', () => {
  if (btn.dataset.state === 'pending') return;
  clearTimeout(timer);
  set('pending', 'Confirming subscription');
  timer = setTimeout(() => {
    if (failBox.checked) {
      set('error', 'That address was rejected. Try again.');
    } else {
      set('done', 'Subscribed. Check your inbox to confirm.');
    }
    timer = setTimeout(() => set('idle', failBox.checked ? 'Not subscribed yet.' : 'Subscribed.'), 1400);
  }, 1300);
});
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 Loading 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: Success Checkmark Morph Button
Source: https://codefronts.com/components/css-loading-buttons/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.
## HTML
```html
<div class="lb-06">
  <div class="lb-06__stage">
    <section class="lb-06__card" aria-labelledby="lb-06-h">
      <h2 class="lb-06__h" id="lb-06-h">Weekly digest</h2>
      <p class="lb-06__sub">Sent Fridays. Written by Jordan Lee. One click to confirm — the spinner becomes the tick.</p>

      <div class="lb-06__form">
        <span class="lb-06__input" aria-hidden="true">jordan@fieldnotes.dev</span>
        <button class="lb-06__btn" type="button" id="lb-06-btn" data-state="idle" aria-busy="false">
          <span class="lb-06__mark" aria-hidden="true">
            <svg viewBox="0 0 20 20" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round">
              <circle class="lb-06__ring" cx="10" cy="10" r="7.4"/>
              <path class="lb-06__check" d="M5.4 10.4 8.6 13.6 14.8 6.8"/>
              <path class="lb-06__cross" d="M6.4 6.4l7.2 7.2M13.6 6.4l-7.2 7.2"/>
            </svg>
          </span>
          <span class="lb-06__faces">
            <span class="lb-06__face lb-06__face--idle">Subscribe</span>
            <span class="lb-06__face lb-06__face--pending">Confirming</span>
            <span class="lb-06__face lb-06__face--done">Subscribed</span>
            <span class="lb-06__face lb-06__face--error">Try again</span>
          </span>
        </button>
      </div>

      <label class="lb-06__toggle"><input type="checkbox" id="lb-06-fail"> Make the next attempt fail</label>
      <p class="lb-06__live" id="lb-06-live" role="status" aria-live="polite">Not subscribed yet.</p>
    </section>
  </div>
</div>
```
## CSS
```css
.lb-06 {
  width: 100%;
  min-height: 100vh;
  display: block;
  box-sizing: border-box;
  --bg: #eef2ec;
  --surface: #ffffff;
  --ink: #1d2a22;
  --muted: #63776a;
  --rule: #d5e0d6;
  --accent: #2f6b4f;
  --mark: #ffffff;
  --bad: #a63b2c;
  --draw: 260ms;
  --hold: 1400ms;
  font-family: ui-sans-serif, system-ui, "Segoe UI", sans-serif;
  background: var(--bg);
  color: var(--ink);
  padding: 40px 18px;
}

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

.lb-06__stage {
  display: grid;
  place-items: start center;
  max-inline-size: 100%;
  min-height: calc(100vh - 80px);
}

.lb-06__card {
  inline-size: 100%;
  max-inline-size: 32rem;
  min-inline-size: 0;
  background: var(--surface);
  border-radius: 20px;
  padding: 26px 24px;
  box-shadow: 0 20px 40px -28px rgba(29, 42, 34, .55), 0 1px 0 var(--rule);
}

.lb-06__h {
  margin: 0;
  font-size: 21px;
  font-weight: 620;
  letter-spacing: -.02em;
}

.lb-06__sub {
  margin: 8px 0 20px;
  font-size: 13px;
  line-height: 1.6;
  color: var(--muted);
  max-inline-size: 46ch;
}

.lb-06__form {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
  align-items: center;
  min-inline-size: 0;
}

.lb-06__input {
  flex: 1 1 12rem;
  min-inline-size: 0;
  font-size: 13px;
  color: var(--muted);
  background: var(--bg);
  border: 1px solid var(--rule);
  border-radius: 999px;
  padding: 12px 16px;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.lb-06__btn {
  appearance: none;
  flex: none;
  border: 0;
  background: var(--accent);
  color: var(--mark);
  border-radius: 999px;
  font: inherit;
  font-size: 13.5px;
  font-weight: 600;
  padding: 0 20px 0 16px;
  min-block-size: 44px;
  min-inline-size: 0;
  display: inline-flex;
  align-items: center;
  gap: 9px;
  cursor: pointer;
  transition: background 240ms ease;
}

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

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

.lb-06__btn[data-state="error"] {
  background: var(--bad);
}

.lb-06__mark {
  display: inline-flex;
  flex: none;
}

.lb-06__ring,
.lb-06__check,
.lb-06__cross {
  transition: stroke-dashoffset var(--draw) ease, opacity 160ms linear;
}

.lb-06__ring {
  stroke-dasharray: 12 46;
  stroke-dashoffset: 0;
  transform-box: fill-box;
  transform-origin: 50% 50%;
}

.lb-06__check {
  stroke-dasharray: 24;
  stroke-dashoffset: 24;
}

.lb-06__cross {
  stroke-dasharray: 22;
  stroke-dashoffset: 22;
}

.lb-06__btn[data-state="idle"] .lb-06__ring {
  stroke-dasharray: 47 0;
}

.lb-06__btn[data-state="pending"] .lb-06__ring {
  animation: lb-06-spin 700ms linear infinite;
}

.lb-06__btn[data-state="done"] .lb-06__ring {
  opacity: 0;
}

.lb-06__btn[data-state="done"] .lb-06__check {
  stroke-dashoffset: 0;
}

.lb-06__btn[data-state="error"] .lb-06__ring {
  opacity: 0;
}

.lb-06__btn[data-state="error"] .lb-06__cross {
  stroke-dashoffset: 0;
}

@keyframes lb-06-spin {
  to {
    transform: rotate(1turn);
  }
}

.lb-06__faces {
  display: grid;
  place-items: center;
}

.lb-06__face {
  grid-area: 1 / 1;
  white-space: nowrap;
  opacity: 0;
  visibility: hidden;
  transition: opacity 180ms ease, visibility 180ms;
}

.lb-06__btn[data-state="idle"] .lb-06__face--idle,
.lb-06__btn[data-state="pending"] .lb-06__face--pending,
.lb-06__btn[data-state="done"] .lb-06__face--done,
.lb-06__btn[data-state="error"] .lb-06__face--error {
  opacity: 1;
  visibility: visible;
}

.lb-06__toggle {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  margin: 18px 0 0;
  font-size: 12px;
  color: var(--muted);
  min-height: 44px;
}

.lb-06__toggle input {
  accent-color: var(--accent);
  inline-size: 15px;
  block-size: 15px;
}

.lb-06__live {
  margin: 4px 0 0;
  font-size: 12.5px;
  color: var(--muted);
  min-inline-size: 0;
}

@media (prefers-reduced-motion: reduce) {
  .lb-06__ring,
    .lb-06__check,
    .lb-06__cross {
    transition: none;
  }

  .lb-06__btn[data-state="pending"] .lb-06__ring {
    animation: none;
    stroke-dasharray: 24 24;
  }
}

@media (prefers-color-scheme: dark) {
  .lb-06 {
    --bg: #10160f;
    --surface: #18211a;
    --ink: #e7f0e6;
    --muted: #94a798;
    --rule: #263226;
    --accent: #4fa87a;
    --mark: #0e150f;
    --bad: #e07a68;
  }
}

[data-theme="dark"] .lb-06 {
  --bg: #10160f;
  --surface: #18211a;
  --ink: #e7f0e6;
  --muted: #94a798;
  --rule: #263226;
  --accent: #4fa87a;
  --mark: #0e150f;
  --bad: #e07a68;
}
```

## JavaScript
```js
const btn = document.getElementById('lb-06-btn');
const live = document.getElementById('lb-06-live');
const failBox = document.getElementById('lb-06-fail');
let timer;

const set = (state, message) => {
  btn.dataset.state = state;
  btn.setAttribute('aria-busy', String(state === 'pending'));
  live.textContent = message;
};

btn.addEventListener('click', () => {
  if (btn.dataset.state === 'pending') return;
  clearTimeout(timer);
  set('pending', 'Confirming subscription');
  timer = setTimeout(() => {
    if (failBox.checked) {
      set('error', 'That address was rejected. Try again.');
    } else {
      set('done', 'Subscribed. Check your inbox to confirm.');
    }
    timer = setTimeout(() => set('idle', failBox.checked ? 'Not subscribed yet.' : 'Subscribed.'), 1400);
  }, 1300);
});
```

How this works

Drawing a path is a length trick, not a shape trick. Set stroke-dasharray to the path's own length so it becomes a single dash, then push it out of view with an equal stroke-dashoffset. Transition the offset back to zero and the stroke appears to be drawn by a pen. The check here is two segments of one <path>, so one offset transition draws the whole tick in the right order.

The morph is the ring and the check sharing a box. The spinner is a circle with a short dash chasing its own circumference; on success the circle collapses its stroke to nothing while the check's offset runs to zero in the same 260 ms. Because both live in the same 20×20 SVG viewBox with the same stroke width and colour, the eye reads one mark changing rather than two elements swapping.

Hold the success state, then leave. A tick that vanishes in 150 ms is a flicker, and one that never leaves turns a button into a status label. Around 1.4 s is the window: long enough to confirm, short enough that the control is interactive again before the user reaches for it. Keep the timer handle and clear it, or a second submit will revert the first one's tick early.

The trap is animating a path whose length you guessed. If stroke-dasharray is shorter than the real path the draw finishes early and stalls; if it is longer the stroke starts visible. Either measure once with getTotalLength() and write the number into the CSS, or keep the path simple enough that a round overestimate — say 28 for this check — is safely longer than the path itself. Also set vector-effect: non-scaling-stroke if the icon is ever scaled, or the stroke weight drifts between states.

Make it yours

  • Change --mark to draw the tick in a colour other than the button's ink.
  • Slow the draw by raising the --draw duration to 400 ms for a more ceremonial confirmation.
  • Shorten --hold to 800 ms if the button sits in a high-frequency flow.
  • Swap the check path for a plus or an arrow and keep the same offset animation.
  • Set stroke-linecap: butt for a sharper, more technical mark.
  • Keep the success state permanent for one-shot actions such as an RSVP, and drop the revert timer.

Gotchas — read before shipping

  • A stroke-dasharray shorter than the real path length stops the draw halfway with no error.
  • Animating the path's d attribute instead of its stroke offset is not interpolatable in CSS and simply snaps.
  • Without clearTimeout a rapid second submit reverts the first tick while the second request is still running.
  • Scaling the icon without vector-effect: non-scaling-stroke makes the check's weight differ from the spinner's.

Browser support

ChromeSafariFirefoxEdge
49+10+45+49+

SVG stroke-dashoffset has been animatable in CSS for a decade; custom properties set the floor. Nothing here needs @property, oklch or :has, so the only degradation on an old engine is a flatter surface.

Techniques used in this demo

Search CodeFronts

Loading…